# @cavuno/board skills (v4.7.0) > The complete agent-skill corpus for building a custom job-board frontend > on the Cavuno Board API with the @cavuno/board SDK. Machine-readable > index: https://cavuno.com/.well-known/skills/index.json — per-skill fetch: > https://cavuno.com/.well-known/skills/. OpenAPI spec: > https://cavuno.com/api/v1/openapi.json. ## cavuno-board-account --- name: cavuno-board-account description: Candidate self-service boundary with @cavuno/board. Use for account, profile, resume onboarding, recommended jobs, avatar, experience, education, skills, languages, or notification preferences. --- # Candidate self-service boundary Signed-in candidate data lives under `board.me`. Browser calls use the bearer token in `auth.storage`; server calls pass it per request in `options.headers`. See `cavuno-board-auth` for cookie-based server sessions. Anonymous methods here are token-based email unsubscribe and email-change confirm (`confirmEmailChange`). Applications, employer companies, messaging, and alerts have their own skills. The host application owns forms, file pickers, and cookie plumbing; this SDK surface supplies data operations. ## Account and profile Account deletion is a synchronous, irreversible cascade over the profile, collections, saved jobs, alerts, avatar, and resume. Obtain explicit confirmation before calling it. Change the password with the current password. The SDK persists the returned session so the caller stays signed in. Passwordless accounts (magic-link or OAuth) get `no_password` — send them through `auth.forgotPassword` instead. ```ts snippet const me = await board.me.retrieve(); await board.me.delete(); await board.me.updatePassword({ currentPassword: 'oldpass99', newPassword: 'newpass99', }); await board.me.requestEmailChange({ email: 'new@example.com' }); await board.me.confirmEmailChange({ token }); const profile = await board.me.profile.retrieve(); await board.me.profile.update({ headline: 'Staff Engineer', jobSearchStatus: 'open_to_offers', profileVisibility: 'public', }); const { available } = await board.me.profile.handleAvailable('jane'); ``` Profile updates are merge-patches. Handle availability is advisory—your current handle counts as available—and the write re-checks uniqueness. ## Experience and education Both collections are id-keyed CRUD with merge-patch updates. Experience creation requires `title`, `companyName`, and `startDate`; education creation requires `institutionName`. ```ts snippet const page = await board.me.profile.listExperience(); const experience = await board.me.profile.createExperience({ title: 'Staff Engineer', companyName: 'Acme', startDate: '2022-01', }); await board.me.profile.updateExperience(experience.id, { endDate: '2025-06', }); await board.me.profile.deleteExperience(experience.id); const education = await board.me.profile.createEducation({ institutionName: 'Example University', }); await board.me.profile.listEducation(); await board.me.profile.updateEducation(education.id, {}); await board.me.profile.deleteEducation(education.id); ``` ## Full-set fields `updateSkills` and `updateLanguages` replace the whole ordered set. Read-modify-write any value the user intends to retain. ```ts snippet const current = await board.me.profile.listSkills(); await board.me.profile.updateSkills({ skills: [...current.data.map((skill) => skill.name), 'TypeScript'], }); await board.me.profile.updateLanguages({ languages: [{ name: 'English', proficiency: 'native' }], }); ``` Both updates return the complete updated list. ## Files The SDK builds multipart `FormData` with field `file` from a `Blob` or `File`. Avatar uploads accept JPEG, PNG, or WebP up to 5 MB. ```ts snippet const { avatarUrl } = await board.me.profile.uploadAvatar(file); ``` Resume upload starts an asynchronous parse that may populate the profile. Poll within a fixed budget, surface failure, and keep manual editing available if parsing runs long. ```ts snippet let resume = await board.me.resume.upload(file, { keepResumeOnFile: true, }); const maxPolls = 30; for (let poll = 0; resume.parseStatus === 'parsing' && poll < maxPolls; poll++) { await new Promise((resolve) => setTimeout(resolve, 2000)); resume = await board.me.resume.retrieve(); } if (resume.parseStatus === 'parsed') { await board.me.profile.retrieve(); } else if (resume.parseStatus === 'failed') { showParseFailure(resume.parseFailureReason); } else { showDelayedParseState(); } ``` Upload options also include `importMode: 'append_only' | 'replace_all'` and `confirmReplaceAll`. `resume.file.url` is a short-lived signed URL. `parseStatus` is null before any parse. `board.me.resume.delete()` removes the stored file and keep-on-file consent while retaining imported profile fields. ## Recommended jobs `board.me.recommendedJobs.list` is the sibling of `savedJobs.list`. Each item wraps the same slim `PublicJobCard` (`{ object: 'recommended_job', job }`). Order is the ranking. The endpoint never returns scores, weights, or ranker identity. Cold start is an empty list — no hint field. Drive an upload-resume CTA from `board.me.profile` / `board.me.resume` (`parseStatus`, skills), not from the list response. ```ts snippet const { data } = await board.me.recommendedJobs.list({ limit: 20 }); data[0]?.job.title; const profile = await board.me.profile.retrieve(); const skills = await board.me.profile.listSkills(); const resume = await board.me.resume.retrieve(); if (data.length === 0 && (skills.data.length === 0 || resume.parseStatus !== 'parsed')) { promptResumeUpload(); } ``` ## Notification preferences Authenticated settings expose `messageEmails` and `applicationEmails`. Updating one channel returns the full set. ```ts snippet const preferences = await board.me.notificationPreferences.retrieve(); await board.me.notificationPreferences.update({ channel: 'messageEmails', subscribed: false, }); ``` Email links use an anonymous HMAC token as authorization. Read all three inputs from the link query: ```ts snippet await board.me.notificationPreferences.unsubscribeWithToken({ boardUserId, channel: 'applicationEmails', token, }); ``` ## Completion gate - Profile update is visible after retrieval. - Adding a skill preserves every existing skill. - Uploaded `avatarUrl` renders. - Resume parsing reaches `parsed`, reports `parseFailureReason`, or reaches the explicit delayed state without blocking editing. - Recommended jobs render the returned cards in list order; an empty list plus missing skills / unparsed resume shows an upload-resume prompt. - Anonymous unsubscribe works in a logged-out browser. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-api-client --- name: cavuno-board-api-client description: Configure the @cavuno/board API client. Use when creating the shared client, adding global hooks or headers, passing framework fetch options, or calling an endpoint through the typed escape hatch. --- # Configure the Board API client Create one client and keep its shared state board-scoped, not user-scoped. ## Create the shared client ```ts import { createBoardClient } from '@cavuno/board'; export const board = createBoardClient({ board: process.env.PUBLIC_CAVUNO_BOARD!, }); ``` The `board` option accepts a `pk_…` key, `boards_…` ID, or slug. Prefer the immutable, publishable `pk_…` key for deployed frontends. The client defaults to `https://api.cavuno.com`; use `baseUrl` only for a Cavuno-supplied alternate origin. **Complete when:** the app imports one reused client and `board.context()` identifies the expected board. ## Configure the request pipeline Every namespace uses the same pipeline: board base path, global headers, stored browser credentials, request/response hooks, and per-call options. ```ts no-check const board = createBoardClient({ board: process.env.PUBLIC_CAVUNO_BOARD!, globalHeaders: { 'Accept-Language': 'en' }, onRequest: async (request) => request, onResponse: async (response, request) => { console.info(request.url, response.status); }, logger: console, }); ``` Use `onRequest` to replace or adjust the request. Use `onResponse` for observation; it receives a response clone, so reading it leaves SDK parsing intact. **Complete when:** cross-cutting behavior is expressed once in client configuration and a request proves the hook or header is active. ## Pass runtime state per call Every method accepts a trailing `FetchOptions`: `RequestInit` without `body`, plus `query`. Fetch-native and framework-specific fields pass through unchanged. ```ts snippet await board.jobs.list( { limit: 20 }, { next: { revalidate: 60, tags: ['jobs'] } }, ); const controller = new AbortController(); await board.jobs.list( { limit: 20 }, { cache: 'force-cache', signal: controller.signal }, ); ``` A module-scoped client is safe for concurrent SSR requests when it uses the server default `nostore` and each request supplies its own bearer or grant headers. `cavuno-board-server-sessions` is the authority for that session pattern. Browser persistence belongs to `cavuno-board-auth`. **Complete when:** a shared server client contains no per-user state and each authenticated request supplies its own headers. ## Record marketing consent for the signed-in person Marketing consent is a property of the board user, never a guest capture. The frontend owns the checkbox wording and the privacy-policy link — render your own copy beside the control; the API records the decision, not the prose. Leave any checkbox unticked by default and call nothing while it stays unticked: absence of a record means no consent, never a default. At sign-up, pass the tick through the register body so consent is recorded in the same transaction that creates the user: ```ts snippet await board.auth.register({ role: 'candidate', method: 'emailpass', email: form.email, password: form.password, displayName: form.displayName, marketingConsent: form.marketingChecked, }); ``` Later, read or change only the signed-in person's own consent through the authenticated `me` namespace. There is intentionally no email parameter, so a frontend cannot target another person. Withdrawal is always an explicit POST — never a state-changing GET, which mail scanners would follow. ```ts snippet const current = await board.me.marketingConsent.retrieve(); if (current?.status !== 'granted') { await board.me.marketingConsent.grant(); } await board.me.marketingConsent.withdraw(); ``` Call `grant()` only from a surface that displayed your disclosure wording. Both calls are idempotent: repeating one changes nothing and emits no event. **Complete when:** the checkbox defaults to unticked, unticked submits nothing, grant is only reachable beside rendered disclosure copy, and withdrawal is never implemented as a state-changing GET. ## Use the escape hatch for an untyped endpoint `board.client.fetch(path, options)` keeps the full request pipeline while providing a response type locally. ```ts snippet const data = await board.client.fetch<{ object: 'list'; data: unknown[]; }>('/some-new-endpoint', { query: { limit: 5 } }); ``` **Complete when:** the call uses a board-relative path, supplies an honest response type, and no parallel raw-fetch client was introduced. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-applications --- name: cavuno-board-applications description: Candidate application handoff with @cavuno/board. Use for native or guest apply, application tracking, resume attachment, withdrawal, and saved jobs. --- # Candidate application handoff Treat applying as a handoff: submission belongs to `board.jobs`; the resulting candidate record belongs to `board.me`. The `me.*` side requires a board-user bearer token. See `cavuno-board-auth` for authentication. External jobs expose `applicationUrl`; link to it instead of creating a native application. Employer pipeline work belongs to `board.me.companies.applicants.*`. The host application owns apply forms and file-pickers; the SDK owns submission data operations. ## Submit `jobs.apply` is optional-auth and idempotent. A signed-in candidate supplies at most `coverNote`; a guest supplies `name` and `email`, when the board permits guest applications. ```ts snippet const application = await board.jobs.apply('senior-chef', { coverNote: 'Excited to cook here.', }); const guestApplication = await board.jobs.apply('senior-chef', { name: 'Ada Lovelace', email: 'ada@example.com', }); ``` Guest applications are claimed through the server-driven magic-link flow; the SDK has no guest-claim method. ## Attach a resume Pass the `Blob` or `File`; the SDK builds multipart `FormData`. A signed-in candidate targets their own application. A guest must pass the id returned by `apply`. ```ts snippet await board.jobs.uploadApplicationResume('senior-chef', file); await board.jobs.uploadApplicationResume('senior-chef', file, { applicationId: guestApplication.id, }); ``` This step is complete when the returned application has `resumeFilename` set. ## Derive the apply-button state `jobs.myApplication` returns the signed-in candidate's application and throws a 404 when none exists. Derive the UI from that result rather than a local submitted flag. ```ts snippet import { isNotFound } from '@cavuno/board'; try { const mine = await board.jobs.myApplication('senior-chef'); renderApplicationStatus(mine.status); } catch (error) { if (!isNotFound(error)) throw error; renderApplyForm(); } ``` `Application.status` is a polled candidate-facing projection of the employer stage: `'applied' | 'interviewing' | 'negotiation' | 'hired' | 'archived'`. Its `job` may be null after job removal. ## Track and manage applications ```ts snippet const page = await board.me.applications.list({ limit: 20 }); // newest first const application = await board.me.applications.retrieve(applicationId); await board.me.applications.updateFacts(applicationId, { coverNote: 'Updated after our call.', }); await board.me.applications.withdraw(applicationId); ``` `updateFacts` merge-patches `candidateName`, `candidateEmail`, `candidateHeadline`, `candidateLocation`, and `coverNote`. `withdraw` permanently deletes the application, so obtain explicit confirmation first. ## Save jobs Saved rows embed the same slim `PublicJobCard` as the jobs list — render them with the same card view-model, and do not expect full-job fields (description, custom field values) on saved rows. `save` converges on the existing row and `unsave` is idempotent. ```ts snippet const saved = await board.me.savedJobs.list({ limit: 20 }); saved.data[0]?.job.title; await board.me.savedJobs.save({ jobId: job.id }); await board.me.savedJobs.unsave(job.id); ``` ## Completion gate - Signed-in submission omits name and email; guest submission includes both. - Repeating `apply` returns the same application id. - Resume upload returns an application with `resumeFilename`. - `myApplication` plus `isNotFound` drives the apply-button state. - Re-fetching after withdrawal removes the application. - Repeating `unsave` leaves the saved list consistent. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-auth --- name: cavuno-board-auth description: Authenticate Cavuno board users. Use for registration, login, logout, token refresh, email verification, password recovery, magic links, OAuth, or authenticated board-user calls. --- # Authenticate board users Board users authenticate with a short-lived bearer access token and a single-use refresh token. The SDK persists returned token pairs through its configured async storage and leaves navigation to the app. ## 1. Choose the storage boundary `auth.storage` accepts `'memory'`, `'local'`, `'session'`, `'nostore'`, or a `CustomStorage` with async `getItem`, `setItem`, and `removeItem` methods. - Browsers default to `'memory'`; choose `'local'` for persistence across tabs and reloads or `'session'` for tab-scoped persistence. - Servers default to `'nostore'`. Keep tokens in the app's httpOnly cookie and follow `cavuno-board-server-sessions` for every SSR session and refresh rule. - A custom store is appropriate when the app already owns a user-scoped persistence boundary. `'local'` and `'session'` are browser runtimes; constructing them off-browser fails immediately. **Complete when:** the selected store matches the runtime and a shared server client remains `nostore`. ## 2. Establish the session ```ts snippet await board.auth.register({ role: 'candidate', method: 'emailpass', email: 'ada@example.com', password: 'a-strong-password', displayName: 'Ada', }); const session = await board.auth.login({ email: 'ada@example.com', password: 'a-strong-password', }); ``` `register`, `login`, `consumeMagicLink`, `exchangeOAuth`, and `refresh` persist the returned session. Use the returned `boardUser` for identity and verification state. For alternate entry points, use `requestMagicLink` / `consumeMagicLink` or the OAuth authorization and exchange methods exposed under `board.auth`. **Complete when:** the chosen entry flow returns a session and `board.me.retrieve()` resolves as that board user. ## 3. Handle expiry explicitly The SDK surfaces an expired access token as `BoardApiError`; it does not refresh and replay a failed request automatically. In a browser app, classify the failure with `isUnauthorized`, serialize refresh through one in-flight promise, then retry the original operation once with the rotated session. ```ts snippet import { isUnauthorized } from '@cavuno/board'; try { return await board.me.retrieve(); } catch (error) { if (!isUnauthorized(error)) throw error; await refreshOnce(); return board.me.retrieve(); } ``` On SSR, use the module-scoped `createSessionRefresher` pattern in `cavuno-board-server-sessions`; it is the single source of truth for concurrency, cookies, retry limits, and per-call authorization headers. **Complete when:** concurrent expiry paths share one rotation and the original operation is attempted at most once after a successful refresh. ## 4. Refresh and logout from the right source `refresh()` and `logout()` read the refresh token from configured storage when their body is omitted. A `nostore` caller passes it explicitly: ```ts snippet await board.auth.refresh({ refreshToken }); await board.auth.logout({ refreshToken }); ``` Successful logout revokes the refresh token and clears SDK storage. A failed logout request retains stored tokens so the app can retry revocation or deliberately choose a local-only sign-out. **Complete when:** logout revokes the server session, clears the app-owned cookie or browser store, and an authenticated read is handled as signed out. ## Verification and recovery ```ts snippet await board.auth.verifyEmail({ token }); await board.auth.forgotPassword({ email: 'ada@example.com' }); await board.auth.resetPassword({ token, password: 'a-new-password' }); ``` The signed-in OTP verification, resend, magic-link, and OAuth branches use their corresponding `board.auth` methods. Password-reset requests preserve account privacy; a successful single-use reset invalidates existing sessions. **Complete when:** each implemented auth route exercises its success state and typed failure state, with bearer tokens absent from server-rendered browser payloads. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-blog --- name: cavuno-board-blog description: Public blog reads with @cavuno/board. Use for post archives, post detail, adjacent or similar posts, tag and author pages, or blog search. --- # Blog Collections return `PublicBlogPostSummary`; only `blog.posts.retrieve` returns `PublicBlogPost` with the rendered `html` body. Read `board.context().features.blog` when deciding whether the board exposes a blog. The host app owns authoring, feeds, sitemaps, and OG-image routes. ## Build archives `blog.posts.list` returns `ListEnvelope`. Its query accepts `limit` (1–100), `cursor`, `tagSlug`, `authorSlug`, and `featured: 'true'`. `featured` is the string literal. Blog lists use cursor pagination and expose neither `offset` nor total `count`. ```ts snippet const page = await board.blog.posts.list({ tagSlug: 'news', limit: 12 }); for (const post of page.data) { post.title; post.customExcerpt; post.coverUrl; post.readingTimeMin; post.authors; post.tags; } const next = page.nextCursor ? await board.blog.posts.list({ tagSlug: 'news', limit: 12, cursor: page.nextCursor, }) : null; ``` Embedded author rows carry `id`, `name`, `slug`, `bio`, `location`, `avatarUrl`, and social URLs (`websiteUrl`, `facebookUrl`, `twitterUrl`, `linkedinUrl`, `githubUrl`). Embedded tag rows carry `id`, `name`, `slug`, and `description`. ## Render a post and canonicalize its slug `posts.retrieve` adds `html`, `ogImageUrl`, `featureImageCaption`, `seoTitle`, `seoDescription`, `redirected`, and `newSlug`. Old slugs still resolve: when `redirected` and `newSlug` are set, redirect to `newSlug` before rendering. ```ts snippet const post = await board.blog.posts.retrieve('hello-world'); if (post.redirected && post.newSlug) { // Redirect to the post route at post.newSlug. } post.html; post.seoTitle ?? post.title; post.seoDescription ?? post.customExcerpt; post.canonicalUrl; post.ogImageUrl ?? post.coverUrl; ``` Fetch detail for the post page; summaries have no `html` field. ## Add post navigation ```ts snippet const { previous, next } = await board.blog.posts.adjacent('hello-world'); // previous is older; next is newer; either can be null. const rail = await board.blog.posts.similar('hello-world', { limit: 6 }); ``` `similar` accepts `limit` 1–20 and defaults to 6. ## Build tag and author pages Tags and authors each provide list plus retrieve-by-slug. Combine a retrieved entity with `posts.list({ tagSlug })` or `posts.list({ authorSlug })` for its archive. ```ts snippet const { data: tags } = await board.blog.tags.list(); const tag = await board.blog.tags.retrieve('news'); const { data: authors } = await board.blog.authors.list(); const author = await board.blog.authors.retrieve('jane'); ``` `PublicBlogTag` carries `id`, `name`, `slug`, and `description`. `PublicBlogAuthor` carries `id`, `name`, `slug`, `bio`, `location`, `avatarUrl`, `websiteUrl`, `facebookUrl`, `twitterUrl`, `linkedinUrl`, and `githubUrl`. ## Search posts `blog.search` posts `BlogSearchBody`: `query` up to 200 characters, optional `cursor`, and `limit` 1–50. It returns `SearchEnvelope`. ```ts snippet const results = await board.blog.search({ query: 'launch', limit: 10 }); results.data[0]?.slug; ``` ## Completion gate Finish only after every applicable check passes: - Archive and search rows render summaries; the post page fetches detail for `html`. - A retrieved old slug redirects to `newSlug` before rendering. - Featured archives send the exact string `featured: 'true'`. - Cursor paging reaches `nextCursor: null` without relying on count or offset. - Newest and oldest posts handle the null side of `adjacent`. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-companies --- name: cavuno-board-companies description: Company catalog reads with @cavuno/board. Use for company indexes, market archives, profiles, company job rails, similar companies, or company salary pages. --- # Companies Use `PublicCompany` for list and search rows. Use `PublicCompanyDetail` from `companies.retrieve` for a profile; it alone adds `markets`. Board-wide jobs use `jobs.*`, board-wide salary hubs use `salaries.*`, and employer self-service uses authenticated `board.me.companies.*`. The host app owns admin writes, sitemaps, and OG-image routes. An approved admin can delete a company they manage and can list, retitle, or remove members. Any approved member can leave with `board.me.companies.leave`. Demoting, removing, or leaving as the last admin is `last_admin`. Admins invite by email; approved members can list pending invites; accept is session-gated on `board.me.acceptInvite`. ```ts snippet await board.me.companies.delete('acme'); const { data: members } = await board.me.companies.listMembers('acme'); await board.me.companies.updateMemberRole('acme', members[0].id, { role: 'admin', }); await board.me.companies.removeMember('acme', members[0].id); await board.me.companies.leave('acme'); const { data: invites } = await board.me.companies.listInvites('acme'); await board.me.companies.createInvite('acme', { email: 'ada@acme.test' }); await board.me.companies.revokeInvite('acme', invites[0].id); const { companySlug } = await board.me.acceptInvite({ token }); ``` ## List and search `companies.list` returns `CompanyListEnvelope`: `ListEnvelope` plus optional market `relatedSearches`. Its query accepts `limit` (1–100), `cursor`, `offset`, and `marketSlug`. `offset` takes precedence over `cursor`; use it with `count` for numbered or parallel paging. An unknown `marketSlug` returns 404. ```ts snippet const page = await board.companies.list({ limit: 20, marketSlug: 'cybersecurity', }); for (const company of page.data) { company.name; company.publishedJobCount; company.links.public; // null when the company has no slug } ``` `companies.search` posts a `CompaniesSearchBody`: `query` matched against the company name (up to 200 characters), optional `marketSlug`, `cursor`, and `limit` (1–100). It returns `SearchEnvelope`. ```ts snippet const results = await board.companies.search({ query: 'acme', limit: 20 }); ``` ## Resolve markets `companies.markets` is callable and carries `.resolve`. The call returns `CompanyMarket` rows (`slug`, `name`, `companyCount`) ranked by company count; it accepts `limit` (1–200, default 100) and optional `search`. Resolve an inbound slug before loading an archive. `TaxonomyResolution` returns `sourceSlug`, `canonicalSlug`, `displayName`, and `redirectTo`. Issue a 308 to `redirectTo` when present, then use the resolved slug as `marketSlug`. ```ts snippet const { data: markets } = await board.companies.markets({ search: 'robotics' }); const market = await board.companies.markets.resolve('cybersecurity'); if (market.redirectTo) { // Return a 308 to the same archive at market.redirectTo. } ``` ## Render a profile `PublicCompany` carries `id`, `name`, `slug`, `website`, `logoUrl`, `description`, `jobCount`, `publishedJobCount`, and `links.public`. `PublicCompanyDetail` adds `markets: CompanyMarketRef[]`, whose rows contain `name` and source `slug`. ```ts snippet const company = await board.companies.retrieve('acme'); company.markets; const jobs = await board.companies.listJobs('acme', { limit: 10 }); const rail = await board.companies.similar('acme', { limit: 6 }); ``` `listJobs` returns the same `JobCardListEnvelope` as `jobs.list` and accepts only cursor plus limit. `similar` accepts `limit` 1–20 (default 6), excludes the current company, and ranks by open roles. Fetch `retrieve` before reading `markets`; list and search rows have no `markets` field. ## Render company salaries `companies.salaries` is callable and carries `.summary` and `.category`. For a profile / overview teaser, prefer `.summary` — it returns `CompanySalarySummary` (overall numbers, top categories, `sampleCount`, `currency`) without seniority, competitors, locations, or logos. Format currency ranges and multi-locale UI strings in the app. The full overview returns `CompanySalary`: nullable `overallSalary`, `bySeniority` rows with board comparison `diffPercent`, `competitors`, `topLocations`, `byCategory`, board-wide baselines, and `currency`. ```ts snippet const teaser = await board.companies.salaries.summary('acme'); teaser.overallSalary; teaser.topCategories; teaser.sampleCount; const overview = await board.companies.salaries('acme'); overview.bySeniority[0]?.diffPercent; const category = await board.companies.salaries.category( 'acme', 'software-engineer', { locale: 'de' }, ); category.categorySourceSlug; category.categoryCanonicalSlug; ``` Pass `{ locale }` for board-language category names on `.category`. Company identity remains untranslated. The API returns both the immutable English `categorySourceSlug` and the board-language `categoryCanonicalSlug`; the host route issues a 308 when the inbound category slug differs from the canonical one. Gate a Salaries tab with `company.salarySampleCount > 0` from `companies.retrieve` rather than fetching salary documents just for presence. ## Completion gate Finish only after every applicable check passes: - A known company is a `public_company`; only its retrieve response has a `markets` array. - A listed market slug works as `marketSlug`, while an invented one produces a handled 404. - Cursor pagination reaches `nextCursor: null` without repeating a page. - A non-canonical market or salary-category route returns a 308 to the slug supplied by the API. - Salary figures and comparisons come directly from `CompanySalary` fields. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-errors --- name: cavuno-board-errors description: Classify Board API failures and board-password challenges. Use when mapping SDK errors to UI states, retries, support diagnostics, or password-gated access. --- # Handle errors and access gates Every non-2xx SDK response throws `BoardApiError`. Branch on its typed guards and codes; messages remain presentation text rather than control flow. ## Error contract ```ts no-check class BoardApiError extends Error { status: number; code: string; details?: unknown; requestId?: string; raw: unknown; } ``` Use the narrowest matching guard, render its domain state, and rethrow unmatched failures: ```ts snippet import { isBoardApiError, isNotFound, isRateLimited, isUnauthorized, isValidationError, } from '@cavuno/board'; try { return await board.jobs.retrieve('senior-chef'); } catch (error) { if (isNotFound(error)) return null; if (isUnauthorized(error)) return showSignIn(); if (isValidationError(error)) return showFieldErrors(error.details); if (isRateLimited(error)) return scheduleRetry(); if (isBoardApiError(error)) { console.error(error.code, error.requestId); } throw error; } ``` The remaining status guards are `isForbidden` for 403 and `isConflict` for 409. Record `code` and `requestId` in support diagnostics. Because v1 may add error codes, unmatched `BoardApiError` instances still need a safe generic state. **Complete when:** every expected failure has one typed branch, an invalid resource renders not-found, and unmatched failures retain their original error. ## Board-password challenge `isBoardPasswordRequired` distinguishes the `board_password_required` 401 from an expired board-user session. Exchange the visitor's password once; browser storage then attaches the returned grant as `X-Board-Access` on later reads. ```ts snippet import { isBoardPasswordRequired } from '@cavuno/board'; try { return await board.jobs.list({ limit: 20 }); } catch (error) { if (!isBoardPasswordRequired(error)) throw error; await board.password.verify(userEnteredPassword); return board.jobs.list({ limit: 20 }); } ``` The grant is board access, not a user session. Server-rendered apps keep it in the app-owned grant cookie and pass it per request; `cavuno-board-server-sessions` defines that cookie and redirect contract. A fresh `board_password_required` response means the grant expired or the password rotated, so return to the challenge flow. **Complete when:** a valid password unlocks one retry, an invalid password renders its typed error, and a stale server grant is cleared before rechallenge. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-filters --- name: cavuno-board-filters description: Listing-filter contracts with @cavuno/board. Use for job filter controls, sort controls, listing URL validation, or taxonomy-backed filter options. --- # Listing filters `@cavuno/board/filters` is the shared vocabulary and parser for every job listing route, including category, skill, and location pages. Display labels live in `@cavuno/board/format`; search POST bodies use `jobs.search`; typeahead UI behavior lives in `cavuno-board-search-suggestions`. ## Parse public URL input Run every listing URL through `parseListingFilters`. Public input is permissive: unknown values are dropped and parsing does not throw. Seniority and company accept repeated parameters or comma-separated strings, then trim, lowercase, deduplicate, and preserve order. Company is an open set of public slugs capped at the first 10 values. ```ts snippet import { DEFAULT_SORT, parseListingFilters, } from '@cavuno/board/filters'; const filters = parseListingFilters(rawSearchParams); const selectedSort = filters.sort ?? DEFAULT_SORT; const page = await board.jobs.list({ limit: 20, seniority: filters.seniority, companySlug: filters.company, remoteOption: filters.remoteOption ? [filters.remoteOption] : undefined, employmentType: filters.employmentType ? [filters.employmentType] : undefined, }); ``` Company slugs are the URL identity. Map `filters.company` directly to `companySlug` in `jobs.list` queries or `jobs.search` filters. ## Render controls from the vocabulary ```ts snippet import { EMPLOYMENT_TYPES, JOB_SORTS, REMOTE_OPTIONS, SENIORITIES, } from '@cavuno/board/filters'; // Wire enums only — display labels are application-owned chrome. const seniorityOptions = SENIORITIES; const sortOptions = JOB_SORTS; ``` Render seniority as a multi-select with all eight `SENIORITIES`. `EMPLOYMENT_TYPES` contains five listing options; `volunteer` and `other` remain valid job wire values but are absent from the filter control. `JOB_SORTS` contains exactly `relevance`, `newest`, and `salary_high`; `relevance` is the featured-ranked default. Label each option with your application's copy (message catalog or hard-coded board language). ## Load taxonomy options Category and skill collections contain terms backed by published jobs. Each term already carries a board-language `displayName`, immutable English `sourceSlug` for filtering, and board-language `canonicalSlug` for links. ```ts snippet const categories = await board.taxonomy.categories.list({ limit: 50 }); const skills = await board.taxonomy.skills.list({ limit: 50 }); const categoryOptions = categories.data.map((term) => ({ label: term.displayName, filterValue: term.sourceSlug, href: `/jobs/${term.canonicalSlug}`, })); ``` Category and skill lists accept `q`, `limit` (1–100), and opaque `cursor`. Pass `nextCursor` unchanged to the next request. ```ts snippet const first = await board.taxonomy.categories.list({ limit: 50 }); const second = first.nextCursor ? await board.taxonomy.categories.list({ limit: 50, cursor: first.nextCursor, }) : null; ``` Keyword suggestions accept `q`, `limit`, and optional `types`. A present query shorter than two characters returns no results. Restrict to taxonomy terms with `types: ['category', 'skill']`; because both may share a slug, key each option by `termType + canonicalSlug`. ```ts snippet const { items } = await board.search.suggest({ q: searchText, limit: 10, types: ['category', 'skill'], }); ``` The host router owns URL serialization and saved-filter persistence. Locations come from `board.taxonomy.places` rather than a static filter export. ## Completion gate Finish only after every applicable check passes: - `/jobs?seniority=Senior,%20lead&sort=oldest` selects senior and lead, falls back to `DEFAULT_SORT`, and passes no invalid value to the SDK. - The seniority control has all eight localized levels and supports multiple selections. - The employment control has five options; the sort control has exactly relevance, newest, and salary high. - Category and skill filtering sends `sourceSlug`, while links use `canonicalSlug`. - Every paged taxonomy request forwards the previous opaque `nextCursor`. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-format --- name: cavuno-board-format description: Board-language display formatting with @cavuno/board/format. Use for salary ranges, dates, custom-field display, or salary-stat numbers. --- # Display formatting `@cavuno/board/format` matches the hosted board's display rules for **contract and data-shaping** helpers. Chrome words (enum→label, uiCopy, salary lexicon) are application-owned (ADR-0104) and are not exported here. Read the board language once and pass it as the required first argument to every label-producing helper. Filter controls use `cavuno-board-filters`. Salary values remain in their wire currency and retain their server-computed amounts. ## Format job and blog fields ```ts snippet import { formatDate, formatPublishedRelativeDate, formatSalaryRange, formatSalaryStat, formatSalaryStatRange, resolveCustomFieldDisplay, } from '@cavuno/board/format'; const { language } = await board.context(); const salary = formatSalaryRange( language, job.salaryMin, job.salaryMax, job.salaryTimeframe, job.salaryCurrency, ); // salary: { text, timeframe, bound: 'range' | 'from' | 'upTo' } | null // Pieces — application composes order (do not paste a join template): // text = Intl amount/range (e.g. "$90–120K", "90.000–120.000 €") // timeframe = the WIRE ENUM ("per_year" … "per_hour") or null — // never a word. Map it through your own catalog. // bound = which open-range chrome word to attach, if any // When joining text + timeframe (or any mixed-direction operands), isolate // each side: U+2066 FSI … U+2069 PDI, or HTML / dir="auto". // Small rates default to standard by magnitude (|value| < 1000) so // cents survive: formatSalaryRange(lang, 22.5, null, 'per_hour', 'USD') // → { text: "$22.50", timeframe: "per_hour", bound: "from" } // Pass notation 'compact' only when you want forced compact glue. formatPublishedRelativeDate(language, job.publishedAt); formatDate(language, job.publishedAt); // Salary-page stats (currency required — not USD-hardcoded). // notation matches formatSalaryRange: omit for magnitude default // (|value| ≥ 1000 → compact; smaller → standard). Pass 'standard' for // full figures ($90,000) or 'compact' to force short form. formatSalaryStat(language, 90000, detail.currency); formatSalaryStatRange(language, 90000, 120000, detail.currency); formatSalaryStatRange(language, 90000, 120000, detail.currency, 'standard'); // Custom fields — language first (ADR-0057). number → kind: 'number' (raw); // multi_select → kind: 'multi_select' with values: string[] (labels). // Do not String(n). Do not join multi-select in the SDK — app owns list // style (conjunction / short / chips): // new Intl.ListFormat(language, { style: 'long', type: 'conjunction' }) // .format(entry.values) const fields = resolveCustomFieldDisplay( language, context.customFields.job, job.customFieldValues, ); ``` `formatPublishedRelativeDate` produces the short relative value used on job cards and the job-detail header (locale-owned RTF `style: 'short'`). `formatDate` and `formatMonthYear` produce UTC-pinned absolute forms for blog metadata and detail facts. The salary timeframe ships as the wire enum on `timeframe` alone — not glued to `text` with a space or `/`. Open-range chrome is also application-owned via `bound`. Compose amount, unit, and chrome in board-language order (prefix, postfix, particles, no-space gluing for ja/zh), with bidi isolation when directions may differ. Missing/`null` currency is not treated as USD: helpers return `null`. Pass the job's real `salaryCurrency`. Invalid or unsupported locales return `null` rather than English or host-default fallbacks; underscore tags like `ja_JP` are normalized to BCP-47 and checked with `Intl.NumberFormat.supportedLocalesOf`. ## Saved-job cards `me/saved-jobs` embeds the same slim `job_card` as the jobs list. Map it with the same card view-model as listings — do not convert a full job client-side. ```ts snippet // saved.job is already PublicJobCard const card = toJobCardVM(saved.job, { language, /* … */ }); ``` ## Completion gate Finish only after every applicable check passes: - Every label-producing call receives `board.context().language`; no call site substitutes a hardcoded locale or relies on a silent `en` default. - Amount and timeframe stay separate until the application joins them; the SDK does not emit a pre-joined `"$90K / year"`. - Open ranges return `bound: 'from' | 'upTo'`; the application adds open-range chrome and composes order itself. - Saved jobs arrive as cards; no `fullJobToCard` conversion. - Salary-stat formatters receive the detail's `currency` (never invent USD). - Formatting changes neither salary amounts nor invents chrome words. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-i18n --- name: cavuno-board-i18n description: One-seam board localization with @cavuno/board. Use when localizing UI chrome, adding locale routing, or preserving board-language content. --- # One-seam board localization A Cavuno board has one content language: `board.context().language`. Jobs, companies, and blog content remain in that API-served language. Multi-locale frontends localize chrome—labels, headings, filters, and FAQ scaffolding—plus entity data the API already translates, such as taxonomy, places, and salary names. ## Own the chrome catalog Applications own chrome words. The SDK does not export a chrome-copy runtime or seed `messages/` (ADR-0104). Keep a typed copy seam in application code (the starter already commits `messages/*.json` for en/de/fr). ```ts snippet // Application-owned: load messages/ for the active locale and expose a // seam components import. Shape is a fixed set of string groups (jobCard, // jobSearch, jobDetail, apply, alerts, …). export type BoardCopy = { jobCard: { featuredLabel: string; /* … */ }; // … }; ``` ## Build the copy seam All UI strings resolve through one application module. Prefer compile-time messages (Paraglide / inlang) fed by application-owned `messages/` files: 1. Keep `messages/` in the application (or starter). 2. Compile messages with your framework's i18n tool. 3. Expose one seam modules components import (`src/copy.ts` or equivalent). Components import this seam rather than reading JSON ad hoc. That keeps the catalog-to-generated-code transition to one module. ## Add locales Use the installed framework and i18n guidance for routing, SSR locale detection, link rewriting, and compiled messages. Keep the board language unprefixed at `/` and prefix additional locales such as `/de/` or `/fr/`. Preserve these Cavuno invariants while doing so: 1. Start from your application message catalogs so chrome wording is under version control with the app. 2. Point the seam at the URL locale. This step is complete when switching locale changes chrome. 3. Keep API content in `board.context().language`. This step is complete when locale switching leaves job, company, and blog fields unchanged. 4. Emit locale-aware ``, canonical URLs, `hreflang`, and sitemap entries. This step is complete when each public locale route declares itself and all alternates consistently. A compile-time i18n system can replace the seam's backing source. Keep the seam provider-free unless the chosen framework integration itself requires a provider. ## Own the five ADR-0103 obligations The SDK formats amounts, units, and relative dates with `Intl` and returns structure. It does **not** ship plural selection, gender agreement, bidi isolation, grapheme-safe truncation, or locale-aware case mapping. Those five are application-owned (ADR-0103). Satisfying the chrome / routing gates alone is not enough — a board that ships without plural rules or bidi isolation still fails this skill. ### Plural selection Never branch on `count === 1`. Use `Intl.PluralRules` for the active locale and pick the catalog form for the returned category: ```ts snippet const pr = new Intl.PluralRules(locale); // categories: zero | one | two | few | many | other (locale-dependent) const form = messages.jobCount[pr.select(count)]; // en: { one: "{n} job", other: "{n} jobs" } // ar: { zero, one, two, few, many, other } — all six can be required const label = form.replace('{n}', new Intl.NumberFormat(locale).format(count)); ``` Use this for every count-bearing chrome string (search results, FAQ sample sizes, alert copy). Do not hardcode English `-s`. ### Gender agreement When a catalog string agrees with a person or role (adjectives, participles in fr/de/ru/…), store gendered variants in the catalog and select by the entity's gender metadata — do not invent agreement in code from English stems. ### Bidi isolation When composing SDK display strings with chrome or with mixed-direction data (Latin company name inside an RTL sentence is the standard case), isolate each operand whose direction may differ: ```ts snippet const FSI = '\u2066'; // FIRST STRONG ISOLATE const PDI = '\u2069'; // POP DIRECTIONAL ISOLATE // Prefer HTML / dir="auto" in markup; FSI/PDI in plain-text joins. // `salary.timeframe` is the wire enum (`per_year`) — resolve it through // your catalog first, then isolate both operands before joining. const unit = salary.timeframe ? m[`salary_${salary.timeframe}`]() : ''; const line = `${FSI}${salary.text}${PDI} ${FSI}${unit}${PDI}`; ``` Never paste a bare `` `${amount} ${label}` `` into RTL chrome without isolation. The SDK returns bare `string` values on purpose — isolation is app-owned at the join site. ### Grapheme-safe truncation Do not slice with `string.length` / `substring` on user-visible text. Count grapheme clusters (`Intl.Segmenter` with `granularity: 'grapheme'`, or a well-tested grapheme library) so emoji ZWJ sequences and combining marks are not split. ### Locale-aware case mapping Use `toLocaleLowerCase(locale)` / `toLocaleUpperCase(locale)` for display case (Turkish `i`/`İ`, Greek final sigma). Keep identifier folding (`toLowerCase` without locale) only for URL slugs and Set keys — the SDK does that on purpose. ### Native digits When formatting numbers the app owns (custom-field `kind: 'number'`, counts, pagination), use `Intl.NumberFormat(locale)` so native-digit locales (`ar`, `bn`, `hi-u-nu-deva`, …) render correctly. Never `String(n)` for display. ## Completion gate - Every authored chrome string enters components through the copy seam. - Chrome comes from application messages, not from `board.context()` (the API no longer serves a labels bag). - Each supported locale renders translated chrome at its public URL. - Switching locale preserves API-served job, company, and blog content. - Server HTML uses the active locale in `` with no hydration mismatch. - Canonical, `hreflang`, and sitemap URLs agree for every locale route. - Missing translation keys fail generation or tests rather than appearing as raw keys. - Count-bearing chrome uses `Intl.PluralRules` (no `count === 1` English branches). - Composed display strings that mix directions isolate operands (FSI/PDI or ``). - Visible truncation is grapheme-safe; display case mapping uses the locale. - App-owned numbers format via `Intl.NumberFormat`, not `String(n)`. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-job-alerts --- name: cavuno-board-job-alerts description: Job-alert two-lane model with @cavuno/board. Use for anonymous double opt-in, token-managed preferences, or signed-in alert CRUD. --- # Job-alert two-lane model Choose exactly one lane: - Anonymous visitors use `board.jobAlerts.*`: double opt-in, then an HMAC manage token from email. - Signed-in users use `board.me.alerts.*`: bearer-authenticated CRUD, active immediately, with up to 10 alerts per user. Both lanes are available only when `board.context()` reports `features.jobAlerts`. ## Anonymous lane: subscribe and confirm `subscribe` requires consent and deliberately returns the same submitted response for new and existing addresses. `confirm` always resolves; its status owns the landing-page branch. ```ts snippet const submitted = await board.jobAlerts.subscribe({ email: 'ada@example.com', consent: true, frequency: 'weekly', filters: { jobFunctions: ['engineering'], remoteOptions: ['remote'], placeSlugs: ['berlin'], }, }); const result = await board.jobAlerts.confirm({ token }); switch (result.status) { case 'confirmed': case 'already_confirmed': case 'not_found': break; case 'expired': await board.jobAlerts.resendConfirmation({ email }); break; } ``` Only `jobFunctions`, `placeSlugs`, and `remoteOptions` filter digest delivery. Seniority and salary fields are stored but do not scope delivery. The supported cadence is `weekly`. ## Anonymous lane: manage-token transport The read and write transports intentionally use different keys: - `manage` is GET with query `{ subscription, token }`. - Writes carry `{ subscriptionId, token }` in their body. ```ts snippet const state = await board.jobAlerts.manage({ subscription: subscriptionId, token, }); state.email; state.confirmed; state.unsubscribed; state.preferences; await board.jobAlerts.unsubscribe({ subscriptionId, token }); await board.jobAlerts.resubscribe({ subscriptionId, token }); await board.jobAlerts.deletePreference({ subscriptionId, preferenceId, token, }); ``` `unsubscribe` and `resubscribe` accept an optional `preferenceId`. Each managed preference also includes its own `manageToken`. `updatePreference` is a full replacement. Round-trip every retained value and always send `frequency`. ```ts snippet const preference = state.preferences[0]!; await board.jobAlerts.updatePreference({ subscriptionId, preferenceId: preference.id, token, frequency: 'weekly', filters: preference.filters, }); ``` ## Signed-in lane Authenticated alerts use `placeIds`, while anonymous alerts use `placeSlugs`. Create and update accept the same `AlertBody`; update is a full replacement. ```ts snippet const { data: alerts } = await board.me.alerts.list(); alerts[0]?.isActive; alerts[0]?.lastSentAt; alerts[0]?.filters; const alert = await board.me.alerts.create({ frequency: 'weekly', jobFunctions: ['engineering'], remoteOptions: ['remote'], }); const current = await board.me.alerts.retrieve(alert.id); await board.me.alerts.update(alert.id, { frequency: 'weekly', jobFunctions: current.filters.jobFunctions, seniorityLevels: current.filters.seniorityLevels, remoteOptions: current.filters.remoteOptions, placeIds: current.filters.placeIds, salaryMin: current.filters.salaryMin, salaryMax: current.filters.salaryMax, salaryCurrency: current.filters.salaryCurrency, }); await board.me.alerts.remove(alert.id); ``` Removal is the signed-in lane's stop action; there is no pause state. ## Completion gate - The feature flag hides both lanes when disabled. - Anonymous subscribe sends `consent: true` and reveals no subscription existence. - The confirm UI handles all four statuses, including resend after expiry. - Manage reads use `subscription`; manage writes use `subscriptionId`. - Editing one field preserves every other field in both full-replace APIs. - UI promises only weekly delivery filtered by job function, place, and remote option. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-jobs --- name: cavuno-board-jobs description: Job catalog reads with @cavuno/board. Use for job browse or search, job detail, similar jobs, candidate gating, or full-catalog iteration. --- # Jobs Use cards for collections and the full job for detail: `jobs.list`, `jobs.search`, and `jobs.similar` return `PublicJobCard`; only `jobs.retrieve` returns `PublicJob`. Company-scoped collections use `companies.listJobs`. The ungated embeddable widget uses `embed.jobs`. ## Browse and paginate `jobs.list(query)` returns `JobCardListEnvelope`: `data`, `count`, `limit`, `offset`, `hasMore`, `nextCursor`, optional `relatedSearches`, and optional `gatedCount`. `JobsListQuery` accepts `limit` (1–100), `cursor`, `offset`, `companyId`, `companySlug`, `remoteOption`, `employmentType`, `seniority`, `location` with `radius` in kilometres, `category`, and `skill`. Repeated `seniority` values are OR-matched. `offset` takes precedence over `cursor`, so choose one paging mode per request. ```ts snippet const first = await board.jobs.list({ limit: 20, seniority: ['senior', 'lead'], }); const second = first.nextCursor ? await board.jobs.list({ limit: 20, cursor: first.nextCursor }) : null; const page3 = await board.jobs.list({ limit: 20, offset: 40 }); for (const card of first.data) { card.title; card.company?.name; card.links.public; } ``` Render `links.public` as the canonical `/companies/:companySlug/jobs/:jobSlug` URL. ### Filter companies by public slug Frontend URLs carry `companySlug`; pass it directly to the API. Unknown slugs contribute no matches, and a wholly unknown set returns `count: 0` with empty `data`. When `companyId` and `companySlug` are both present, their matches form a union. Each accepts at most 10 values. ```ts snippet const page = await board.jobs.list({ limit: 20, companySlug: ['acme', 'globex'], }); ``` ## Search `jobs.search` posts `JobsSearchBody`: free-text `query`, structured `filters`, and pagination. It returns `JobCardSearchEnvelope`; `companySlug` has the same semantics inside `filters`. ```ts snippet const results = await board.jobs.search({ query: 'chef', filters: { seniority: ['senior'], remoteOption: ['remote'], companySlug: ['acme'], publishedAt: { gte: '2026-01-01T00:00:00Z' }, }, limit: 20, }); ``` ## Render detail and similar jobs ```ts snippet const job = await board.jobs.retrieve('senior-chef'); job.description; // HTML job.officeLocations; job.company?.slug; const rail = await board.jobs.similar('senior-chef', { limit: 5 }); ``` ## Surface candidate gating On a gated board, `gatedCount` reports results withheld from the current viewer. Render it as an upsell. The same optional-auth endpoint returns the entitled view when called with that board user's bearer token. ```ts snippet const page = await board.jobs.list({ limit: 20 }); if ((page.gatedCount ?? 0) > 0) { // Render “Sign in to see N more roles”. } ``` ## Walk the full catalog Use `paginate()` for sitemaps, feeds, and exports. It advances the opaque cursor until `hasMore` is false and removes `offset` after the first request; retaining the offset would make it win over the cursor and repeat a page. ```ts snippet import { paginate } from '@cavuno/board'; for await (const card of paginate(board.jobs.list, { limit: 100 })) { urls.push(card.links.public); } const first500 = await paginate(board.jobs.list).toArray({ limit: 500 }); ``` Default browse order can move while a board changes. Supply an explicit sort or search whenever iteration order must stay stable. ## Completion gate Finish only after every applicable check passes: - Collections render `PublicJobCard`; detail renders `PublicJob`. - Every job link comes from `links.public`. - Pagination returns the final page once and ends with `nextCursor: null`. - A gated anonymous response renders its `gatedCount` upsell, while an entitled request renders the ungated view. - Full-catalog code uses `paginate()` and any order-sensitive walk has an explicit sort or search. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-messaging --- name: cavuno-board-messaging description: Polled messaging contract with @cavuno/board. Use for inboxes, unread badges, threads, read receipts, message moderation, or user blocks. --- # Polled messaging contract Messaging is authenticated employer-to-candidate REST. Near-live behavior comes from polling at a 3–5 second cadence while the page is visible. `list`, `listMessages`, and `unreadCount` are the transport; the SDK exposes no realtime subscription. ## Keep the poll visible Refresh immediately on start or return to the tab, and maintain at most one timer. ```ts snippet const pollMs = 4000; async function refreshInbox() { const [{ count }, inbox] = await Promise.all([ board.me.conversations.unreadCount(), board.me.conversations.list({ limit: 20 }), ]); renderInbox(count, inbox.data); } let timer: ReturnType | undefined; function startPolling() { void refreshInbox(); timer ??= setInterval(refreshInbox, pollMs); } function stopPolling() { if (timer) clearInterval(timer); timer = undefined; } document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') startPolling(); else stopPolling(); }); startPolling(); ``` Inbox rows include `lastMessageAt`, `lastMessageSnippet`, `hasUnread`, and the live-resolved `counterparty` with `displayName`, `avatarUrl`, `companyName`, and `handle`. `list({ archived: true })` reads the archived view; `archive` and `unarchive` are idempotent and per-side. ## Render a thread faithfully Header and messages are separate reads. Messages arrive oldest-first. An unsent message is a tombstone with empty `body` and non-null `deletedAt`; preserve its position with a placeholder. ```ts snippet const conversation = await board.me.conversations.retrieve(conversationId); const { data: messages } = await board.me.conversations.listMessages( conversationId, { limit: 50 }, ); conversation.viewerRole; conversation.viewerLastReadMessageId; for (const message of messages) { message.body; message.deletedAt; message.readAt; message.editedAt; } await board.me.conversations.markRead(conversationId); ``` Call `markRead` when the viewer opens the thread. It clears their unread state and advances read receipts for the counterparty. ## Send `findExisting` routes to an existing thread. Employer-only `start` cold-initiates a candidate; `startAboutApplication` begins in application context; `reply` continues a thread. Each send returns a `Message`. ```ts snippet const existing = await board.me.conversations.findExisting({ candidateBoardUserId, }); const first = await board.me.conversations.start({ candidateBoardUserId, body: 'Hi — your profile looks like a great fit.', }); await board.me.conversations.reply(first.conversationId, { body: 'Following up!', }); ``` The server enforces messaging enablement, cold-message rules, pair limits, and blocks. Handle `messaging_*` `BoardApiError` codes with `cavuno-board-errors`. ## Edit, moderate, and block Own-message edits and unsends have a 15-minute window. Unsend is idempotent and creates a tombstone. Reporting a message addressed to the viewer automatically blocks its author. ```ts snippet await board.me.messages.edit(messageId, { body: 'Fixed typo' }); await board.me.messages.unsend(messageId); const report = await board.me.messages.report(messageId, { reason: 'spam', }); await board.me.blocks.create({ boardUserId }); const blocked = await board.me.blocks.status(boardUserId); await board.me.blocks.remove(boardUserId); ``` Report reasons are `'spam' | 'harassment' | 'misrepresentation' | 'other'`. `board.me.blocks.list()` enumerates blocks. ## Completion gate - Hiding the tab stops the timer; returning starts one timer and refreshes immediately. - A second account's message updates `unreadCount().count` and `hasUnread` within one interval. - Opening a thread calls `markRead`; the sender later sees `readAt`. - Unsend renders a tombstone in place. - Messaging policy errors produce deliberate UI states rather than a generic success path. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-paywall --- name: cavuno-board-paywall description: Grant-is-truth candidate paywall with @cavuno/board. Use for offers, gated job counts, embedded checkout handoff, access confirmation, or subscription portal links. --- # Grant-is-truth candidate paywall The candidate money flow is anonymous offers → authenticated embedded checkout → webhook-backed grant. The grant, rather than checkout completion, owns access. Board-password access is a separate `X-Board-Access` mechanism. Employer posting payments use `cavuno-board-post-a-job`. ## Render offers and gating `board.paywall.offers()` is anonymous and returns an empty list when disabled. Internal Stripe price ids stay server-side. ```ts snippet const { data: offers } = await board.paywall.offers(); for (const offer of offers) { offer.offerKey; offer.label; offer.billingLabel; offer.amountCents; offer.currency; offer.offerType; offer.isDefault; } ``` `offerType` is `'recurring' | 'lifetime'`; post the selected `offerKey` to checkout. Gated catalog reads expose withheld inventory as `gatedCount`. The same jobs endpoint returns the entitled view when called with the candidate bearer token. ```ts snippet const page = await board.jobs.list({ limit: 20 }); if ((page.gatedCount ?? 0) > 0) { renderUpsell(page.gatedCount); } ``` ## Mint and mount checkout `board.me.access.checkout` requires a signed-in candidate profile and returns a mount kit. `returnPath` is relative; the server makes the canonical absolute return URL and appends Stripe's session placeholder. ```ts snippet import { loadStripe } from '@stripe/stripe-js'; const kit = await board.me.access.checkout({ offerKey: 'monthly', returnPath: '/account/access', colorMode: 'light', }); const stripe = await loadStripe(kit.publishableKey, { stripeAccount: kit.stripeAccountId, }); ``` The kit contains `sessionId`, `clientSecret`, `stripeAccountId`, `publishableKey`, and `offerType`. The host owns Stripe.js, embedded-checkout mounting, and redirects. A repeated checkout POST safely creates another session that can expire unpaid. ## Confirm access from the grant `retrieveCheckout` reports `open`, `complete`, or `expired`. Open sessions are remountable with their `clientSecret`; expired sessions require a new checkout. After completion, poll `grant()` within a bounded window because webhook delivery is asynchronous. ```ts snippet const checkout = await board.me.access.retrieveCheckout(kit.sessionId); if (checkout.status === 'complete') { let grant = await board.me.access.grant(); for (let poll = 0; !grant.hasAccess && poll < 10; poll++) { await new Promise((resolve) => setTimeout(resolve, 1000)); grant = await board.me.access.grant(); } if (grant.hasAccess) renderUnlockedJobs(); else renderPendingAccess(); } ``` `grant()` always resolves: no access is `{ hasAccess: false }`. Its `status`, `offerType`, `currentPeriodEnd`, and `cancelAtPeriodEnd` drive the account UI. Gate failures use `paywall_disabled`, `paywall_no_candidate_profile`, `paywall_offer_not_found`, `paywall_already_active`, and `paywall_invalid_checkout_session`; handle them with `cavuno-board-errors`. ## Open the subscription portal Only recurring grants have a portal. The host follows the returned Stripe-hosted URL. ```ts snippet const grant = await board.me.access.grant(); if (grant.offerType === 'recurring') { const { url } = await board.me.access.portal({ returnPath: '/account', }); location.href = url; } ``` A lifetime grant produces `paywall_no_recurring_subscription` for `portal`. ## Completion gate - Anonymous offers render without authorization; disabled paywall renders no tiers. - Unentitled jobs show `gatedCount`; the active-grant request exposes the full list. - Test checkout reaches `complete`, then bounded grant polling reaches `hasAccess: true` after webhook delivery. - UI access decisions read `grant().hasAccess`. - Recurring grants open the portal; lifetime grants show a deliberate non-portal state. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-post-a-job --- name: cavuno-board-post-a-job description: Post a job publicly with @cavuno/board. Use for plan selection, logos, checkout handoff, or verified existing credit. --- # Status-first job posting The public posting funnel is anonymous and rate-limited. Its invariant is status-first handling: `board.jobPosting.create` returns one of four success states, and the host follows that state. Rejected submissions throw `BoardApiError` instead. Employer dashboard job management belongs to `board.me.companies.jobs.*`. Candidate access payments belong to `cavuno-board-paywall`. ## Select a plan ```ts snippet const { data: plans } = await board.jobPosting.plans(); for (const plan of plans) { plan.id; plan.prices; plan.invoiceOnly; plan.isRecommended; plan.features; } ``` Pass the chosen `plan.id` as `submission.selectedPlan`. Invoice-only plans collect `invoiceBilling` rather than immediate payment. ## Resolve a logo Both logo paths return a stored `publicUrl` for `create`. The SDK owns multipart encoding. ```ts snippet const uploaded = await board.jobPosting.uploadLogo(file); const fetched = await board.jobPosting.fetchLogoByDomain('acme.com'); ``` Uploads accept JPEG, PNG, WebP, or GIF up to 2 MB. A missing domain logo throws code `job_posting_logo_not_found`; continue without a logo. ## Submit and exhaust the result ```ts snippet const result = await board.jobPosting.create({ submission: { companyName: 'Acme', contactName: 'Ada', contactEmail: 'ada@acme.com', title: 'Staff Engineer', description: '

', employmentType: 'full_time', remoteOption: 'remote', officeLocations: [], applicationUrl: 'https://acme.com/apply', salaryRangeEnabled: false, selectedPlan: plan.id, }, logoUrl: uploaded.publicUrl, }); switch (result.status) { case 'checkout': location.href = result.checkoutUrl; break; case 'published': linkToJob(result.jobSlug); break; case 'pending_approval': showPending(result.jobId); break; case 'invoice_sent': showInvoiceSent(result.jobId); break; } ``` The mapping is: - paid Stripe plan → `checkout`; - free or credited posting → `published`, or `pending_approval` on moderated boards; - invoice plan → `invoice_sent`. The checkout value is a URL for a host-owned full-page redirect. Payment publication happens by webhook; the SDK exposes no Stripe integration or publish-confirm method. ## Verified existing credit Email verification protects billing ownership. Send verification first, then exchange the token for options; a selected option becomes `selectedBilling`. ```ts snippet await board.jobPosting.sendBillingVerification({ email }); const { options } = await board.jobPosting.getBillingOptions({ verificationToken, }); const option = options[0]; if (option) { option.jobsRemaining; option.featuredRemaining; option.renewsAt; await board.jobPosting.create({ submission, selectedBilling: { type: option.type, id: option.id, planId: option.planId, }, }); } ``` Bare-email billing lookup is intentionally absent because it would expose whether an address holds credit. ## Completion gate - Every `create` call handles all four statuses. - Paid checkout redirects through `checkoutUrl`; the job appears only after webhook completion. - Free unmoderated submission returns a resolving `jobSlug`. - Missing domain logos leave submission usable without `logoUrl`. - Existing credit is selectable only after token verification. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-salaries --- name: cavuno-board-salaries description: Salary read models with @cavuno/board. Use for title, skill, or location salary indexes and detail, cross-axis pages, or the salary companies hub. --- # Salaries The salary namespaces return pre-aggregated read models. Render their figures directly in the response `currency`; server-side weighted averages, medians, percentile bands, and seniority splits are the source of truth. Single-company salary pages use `companies.salaries` and `companies.salaries.category`. A job's own range comes from its `salaryMin`/`salaryMax` fields. ## Build axis indexes Titles, skills, and locations each expose `.list()`. Index items contain `avgSalaryMin`, `avgSalaryMax`, and sample-size `jobCount`; title and skill items also contain `p25SalaryMin`, `p75SalaryMax`, and `currency`. ```ts snippet const titles = await board.salaries.titles.list(); for (const title of titles.data) { title.slug; title.name; title.avgSalaryMin; title.avgSalaryMax; title.jobCount; } const skills = await board.salaries.skills.list(); const companies = await board.salaries.companies.list(); ``` `salaries.companies.list()` ranks companies by sample size. `salaries.locations.list()` returns a flattened place tree: rebuild country, region, and city nesting from each `SalaryLocation.parentSlug`; top-level rows have `parentSlug: null`. ## Retrieve detail and canonicalize slugs Every axis `retrieve(slug)` accepts an inbound English or board-language slug. The result includes immutable English `sourceSlug` and board-language `canonicalSlug`. Pass `{ locale }` for board-language names and canonical slugs, then issue a 308 when the inbound slug differs from `canonicalSlug`. The API itself returns data rather than a redirect. ```ts snippet const title = await board.salaries.titles.retrieve('software-engineer', { locale: 'de', }); title.canonicalSlug; title.overallSalary; title.bySeniority; title.topCompanies; title.currency; const skill = await board.salaries.skills.retrieve('python'); const place = await board.salaries.locations.retrieve('berlin'); ``` `overallSalary` is nullable. Render an empty state when it is `null`. Title detail contains `{ avgMin, avgMax, p25Min, p75Max, jobCount }` there and exposes medians as top-level `boardMedianMin` and `boardMedianMax`. Skill and location details instead include `medianMin` and `medianMax` inside `overallSalary`. Title and skill `bySeniority` rows include board-comparison fields such as `boardAvgMin` and `diffPercent`. ## Build cross-axis pages Titles and skills expose `.locations(slug)` and `.location(slug, locationSlug)`. Locations expose `.titles(slug)` and `.skills(slug)`. ```ts snippet const index = await board.salaries.titles.locations('software-engineer'); index.locations; const berlin = await board.salaries.titles.location( 'software-engineer', 'berlin', ); berlin.categoryCanonicalSlug; berlin.locationCanonicalSlug; berlin.overallSalary; const titlesInBerlin = await board.salaries.locations.titles('berlin'); const skillsInBerlin = await board.salaries.locations.skills('berlin'); ``` A cross-axis detail carries four slug fields: source and canonical slugs for both category and location. Canonicalize both URL segments. Its `overallSalary.p25Min` and `p75Max` may be null. ## Preserve aggregate semantics Display response fields as-is. Client arithmetic such as averaging min/max, deriving medians from percentiles, summing rails, or converting currencies changes the weighted meaning and diverges from the hosted board. Use `jobCount`, rather than `data.length`, for sample-size copy. ## Completion gate Finish only after every applicable check passes: - Stale axis and cross-axis slugs produce 308s to every returned canonical segment. - A non-English board passes `{ locale }` and renders board-language names. - `overallSalary: null` renders an empty state rather than `0` or `NaN`. - Every salary, comparison, and sample size maps directly to a response field and uses the response currency. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-search-suggestions --- name: cavuno-board-search-suggestions description: Build server-ranked search suggestions with @cavuno/board. Use for company-and-term typeahead, autocomplete dropdowns, or suggestion-controller wiring. --- # Server-ranked search suggestions `board.search.suggest` returns one interleaved list of companies, markets, taxonomy terms, blog posts, and tags. Pass `types` to restrict kinds; `limit` applies after that filter. Server rank is the contract: render its order unchanged. For UI, the headless controller adds debounce, abort, and stale-result dropping. Location autocomplete uses `board.taxonomy.places.list({ q })`; filter option lists use `board.taxonomy.categories.list` or `board.taxonomy.skills.list`; job results use `board.jobs.list` or `board.jobs.search`. Route a selected suggestion with `suggestionPath` from `@cavuno/board/paths`. ## Read suggestions ```ts snippet const { items, query } = await board.search.suggest({ q: 'acme', limit: 10, types: ['company', 'skill', 'category'], }); for (const item of items) { if (item.type === 'company') { item.slug; item.name; item.jobCount; } else if (item.type === 'market') { item.slug; item.name; item.companyCount; } else { item.termType; item.displayName; item.canonicalSlug; item.sourceSlug; } } ``` Queries shorter than two characters return no items. `limit` accepts 1–25 and defaults to 25. `{ types: ['skill'], limit: 10 }` returns up to ten skills. Term suggestions have `termType: 'category' | 'skill'`; use `canonicalSlug` for links and `sourceSlug` for job filters. ## Drive UI with the controller ```ts snippet import { createSuggestController } from '@cavuno/board/suggest'; const suggest = createSuggestController(board, { limit: 10 }); const unsubscribe = suggest.subscribe(() => { const state = suggest.getState(); renderSuggestions(state.items, state.status, state.query); }); suggest.setQuery(inputValue); suggest.setExcludedCompanySlugs(appliedCompanySlugs); unsubscribe(); suggest.dispose(); ``` Defaults are a 250 ms debounce and two-character minimum. Previous items remain while status is `loading`. The store is compatible with `useSyncExternalStore`. `setExcludedCompanySlugs` is the view-level exception: it removes company items synchronously without requesting again. Preserve the remaining items and order. The SDK provides the data and controller, while the host owns dropdown markup, keyboard navigation, and framework bindings. ## Apply a company suggestion Company slugs are public URL identity. Write the slug into listing filters and pass it as `companySlug`. ```ts snippet import { parseListingFilters } from '@cavuno/board/filters'; const filters = parseListingFilters(rawSearchParams); const page = await board.jobs.list({ limit: 20, companySlug: filters.company, }); ``` Unknown slugs are dropped server-side. If none resolve, the listing has `count: 0`. When `companySlug` and `companyId` are both present, the server uses their union. ## Completion gate - A controller query below two characters renders no results and makes no request. - Rapid typing renders only the final response. - Excluded companies disappear without changing the relative order of other items. - Selecting a company writes its slug to the URL and sends `companySlug` directly. - Teardown unsubscribes listeners and calls `dispose`. ## Cavuno SDK reference For setup and API details beyond this workflow, use the [Cavuno Board SDK documentation](https://cavuno.com/docs/sdk). --- ## cavuno-board-seo --- name: cavuno-board-seo description: SEO builders with @cavuno/board/seo. Use for JobPosting or breadcrumb JSON-LD, blog schema, salary rich results, or job-listing head metadata. --- # SEO builders `@cavuno/board/seo` returns the hosted board's structured data and framework-neutral listing-head descriptors. ## Select the page branch Read the matching reference completely before implementing that page: - Job detail or breadcrumbs: [`JOB_AND_BREADCRUMBS.md`](JOB_AND_BREADCRUMBS.md) - Blog post or author profile: [`BLOG.md`](BLOG.md) - Salary detail, index, comparison, or FAQ: [`SALARY.md`](SALARY.md) - Job-listing metadata or structural JSON-LD: [`LISTING.md`](LISTING.md) For a page that combines branches, read every matching reference. ## Favicons and board logo Brand identity — not SEO builders. Read them from `board.context()`: ```ts const { logoUrl, icons } = await board.context(); // logoUrl — board logo // icons.ico / .svg / .appleTouch / .icon192 / .icon512 / .iconMaskable512 ``` `board.seo()` is infra tokens only (ads, IndexNow, verification, canonical base, optional `manifest.name`). Sitemaps and robots.txt use `cavuno-board-sitemap`. The host app owns OG-image generation and its route map. `listingHead` receives app-owned title and meta description copy; the SDK never composes those sentences or joins count/heading/board name. ## Apply the shared rendering contract Builders with nullable return types use `null` when no useful object remains. Emit a `