For the developer (or Claude). The Framer job board shows live Ashby jobs to people. A stored "Jobs Snapshot" inside the component is written into the page HTML when the site is published, so AI crawlers (no JavaScript) can read the jobs.
Route A (automatic): the free Cloudflare Worker refreshes the snapshot every 6 hours. Publish the site afterward to push it live.
Route B (manual, no Cloudflare): in the Framer editor, click Copy latest jobs on the component banner, paste into Jobs Snapshot, publish.
Placeholders: PASTE_FRAMER_PROJECT_LINK_HERE, PASTE_KV_ID_HERE. The Framer API key is set as a secret with npx wrangler secret put FRAMER_API_KEY. It is never in this page.
Paste into Framer: Assets > Code > New Component, name it ResponsiveJobBoard. Set Job Board Name to possible-finance.
// @ts-nocheck
/**
* For any website & code related support, contact Sam from wealthisteps.com at wealthistep@gmail.com
* Linkedin - https://www.linkedin.com/in/samagramishra/
*
* MIGRATED TO ASHBY — Public Job Postings API
*
* Uses Ashby's PUBLIC, unauthenticated endpoint, which is built for hosting
* your own careers page. No API key, no proxy, no CORS issues — safe to call
* directly from a Framer component.
*
* GET https://api.ashbyhq.com/posting-api/job-board/{JOB_BOARD_NAME}?includeCompensation=true
*
* Find {JOB_BOARD_NAME}: open your Ashby-hosted board. The last path segment is
* the name. e.g. https://jobs.ashbyhq.com/Acme => JOB_BOARD_NAME is "Acme".
* (In Ashby: Admin -> Theme (Job Board) -> "Job Board" button, top-right.)
*
* Set the "Job Board Name" property control to that value.
*
* HYBRID (no server needed):
* - "Jobs Snapshot": paste the JSON from the API URL above. Framer writes these
* roles into the published HTML, so AI crawlers (no JavaScript) can read them.
* - In the browser the component still fetches Ashby live and replaces the
* snapshot, so visitors always see current roles without a republish.
* - The HTML copy is only as fresh as the last publish. On the Framer canvas a
* banner shows when the snapshot no longer matches Ashby, with a copy button.
*
* Response shape:
* { apiVersion: "1", jobs: [ { title, location, applyUrl, jobUrl,
* isListed, isRemote, workplaceType, department, team, employmentType,
* secondaryLocations: [...], ... } ] }
*/
import { ControlType, RenderTarget } from "framer"
import * as React from "react"
function ChevronDown({ size = 20, className = "" }) {
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
)
}
// Custom dropdown component for filters
function DropdownSearch({ options, value, onChange, placeholder, font }) {
const [isOpen, setIsOpen] = React.useState(false)
const dropdownRef = React.useRef(null)
React.useEffect(() => {
const handleClickOutside = (event) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target)
) {
setIsOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
const handleOptionClick = (option) => {
onChange(option)
setIsOpen(false)
}
return (
<div className="dropdown-wrapper">
<div className="select-wrapper" ref={dropdownRef}>
<div
className="dropdown-display"
onClick={() => setIsOpen(!isOpen)}
>
<span style={{ color: value ? "#000000" : "#767676" }}>
{value || placeholder}
</span>
</div>
<ChevronDown size={20} className="dropdown-chevron" />
{isOpen && options.length > 0 && (
<div className="custom-dropdown">
{/* allow clearing the filter */}
<div
className={`dropdown-option ${!value ? "selected" : ""}`}
onClick={() => handleOptionClick("")}
>
{placeholder}
</div>
{options.map((option) => (
<div
key={option}
className={`dropdown-option ${option === value ? "selected" : ""}`}
onClick={() => handleOptionClick(option)}
onMouseOver={(e) =>
(e.target.style.backgroundColor = "#f0f0f0")
}
onMouseOut={(e) =>
(e.target.style.backgroundColor =
option === value
? "#f5f5f5"
: "transparent")
}
>
{option}
</div>
))}
</div>
)}
</div>
</div>
)
}
// Ashby public API returns `location` as a plain string.
function getLocationName(job) {
return job?.location || "Remote"
}
// Ashby public API gives `applyUrl` (and `jobUrl`) on each posting.
function getApplyLink(job) {
return job?.applyUrl || job?.jobUrl || "#"
}
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
}
// Row markup as a plain HTML string, so the rows are written straight into the
// published HTML (crawlers read it). React keeps that HTML, then updates it live.
function jobRowsHtml(jobs) {
return jobs
.map(
(job) =>
`<a class="job-board-row" href="${esc(getApplyLink(job))}" target="_blank" rel="noopener"><div class="job-board-job-cell"><h3 class="job-board-job-title">${esc(job.title)}</h3><div class="job-board-location-row"><p class="job-board-job-loc">${esc(getLocationName(job))}</p><div class="job-board-more">More info →</div></div></div></a>`
)
.join("")
}
// Accepts the full API response ({ jobs: [...] }) or a plain array.
function parseSnapshot(text) {
if (!text) return []
try {
const data = JSON.parse(text)
const list = Array.isArray(data) ? data : data?.jobs || []
return list.filter((j) => j && j.title && j.isListed !== false)
} catch {
return []
}
}
function snapshotKey(jobs) {
return jobs
.map((j) => `${j.title}|${getLocationName(j)}|${getApplyLink(j)}`)
.sort()
.join("\n")
}
function slimJobs(jobs) {
return JSON.stringify({
jobs: jobs.map((j) => ({
title: j.title,
location: getLocationName(j),
applyUrl: getApplyLink(j),
isListed: true,
})),
})
}
/**
* @framerSupportedLayoutWidth any-prefer-fixed
* @framerSupportedLayoutHeight auto
*/
export function ResponsiveJobBoard(props) {
const {
jobBoardName = "",
jobsSnapshot = "",
placeholderSearch = "Search",
placeholderLoc = "Location",
font,
} = props
const snapshot = React.useMemo(() => parseSnapshot(jobsSnapshot), [jobsSnapshot])
// First render uses the snapshot on both the publish build and the browser,
// so the published HTML contains the roles and hydration matches.
const [jobs, setJobs] = React.useState(snapshot)
const [liveJobs, setLiveJobs] = React.useState(null)
const [copied, setCopied] = React.useState(false)
const isCanvas = RenderTarget.current() === RenderTarget.canvas
React.useEffect(() => {
if (liveJobs === null) setJobs(snapshot)
}, [snapshot])
const [search, setSearch] = React.useState("")
const [locFilter, setLocFilter] = React.useState("")
React.useEffect(() => {
if (!jobBoardName) return
const url = `https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(
jobBoardName
)}?includeCompensation=true`
fetch(url, { mode: "cors" })
.then((res) => {
if (!res.ok) throw new Error(`Status ${res.status}`)
return res.json()
})
.then((data) => {
const all = data.jobs || []
// Only show listed postings publicly. Unlisted ones are meant
// to be reachable by direct link only.
const listed = all.filter((j) => j.isListed !== false)
setLiveJobs(listed)
setJobs(listed)
})
.catch((err) => {
// Keep the snapshot on screen if Ashby can't be reached.
console.error("Fetch jobs error:", err)
})
}, [jobBoardName])
const outdated =
isCanvas && liveJobs !== null && snapshotKey(liveJobs) !== snapshotKey(snapshot)
const copyLatest = () => {
if (!liveJobs) return
navigator.clipboard?.writeText(slimJobs(liveJobs)).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
// dropdown options
const locs = Array.from(new Set(jobs.map((j) => getLocationName(j))))
// filter logic
const filtered = jobs.filter(
(j) =>
(j.title || "").toLowerCase().includes(search.toLowerCase()) &&
(!locFilter || getLocationName(j) === locFilter)
)
return (
<>
<style>
{`
.job-board-container {
width: 100%;
font-family: ${font?.fontFamily || "Haffer, sans-serif"};
}
.job-board-spinner {
padding: 40px;
text-align: center;
color: #767676;
font-size: 16px;
font-family: ${font?.fontFamily || "inherit"};
}
.job-board-filters {
display: grid;
grid-template-columns: 1fr 1fr;
}
.job-board-filter-cell:not(:first-child) {
border-top-color: #00000000;
}
.job-board-filter-cell {
position: relative;
border: 1px solid #DADADA;
background: #FFFFFF;
padding: 16px;
height: 73px;
font-size: 18px;
}
.job-board-search {
width: 100%;
border: none;
outline: none;
font-size: 18px;
color: #000000;
background: transparent;
height: 100%;
padding: 0;
font-family: inherit;
}
.job-board-search::placeholder {
color: #767676;
font-size: 18px;
}
.job-board-search-icon {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
color: #767676;
pointer-events: none;
}
.dropdown-wrapper {
width: 100%;
height: 100%;
position: relative;
}
.select-wrapper {
position: relative;
width: 100%;
height: 100%;
display: block;
}
.dropdown-display {
width: 100%;
height: 100%;
padding: 0px 40px 0px 0px;
color: #000;
border: none;
border-radius: 4px;
cursor: pointer;
appearance: none;
outline: none;
background: transparent;
font-size: 18px;
font-family: ${font?.fontFamily || "inherit"};
display: flex;
align-items: center;
}
.dropdown-display:empty::before {
content: attr(placeholder);
color: #767676;
}
.dropdown-chevron {
position: absolute;
right: 0px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: #767676;
}
.dropdown-option {
padding: 12px 16px;
cursor: pointer;
font-family: ${font?.fontFamily || "inherit"};
font-size: 18px;
background-color: transparent;
transition: background-color 0.2s ease;
color: #000000;
}
.dropdown-option:hover {
background-color: #f0f0f0;
}
.dropdown-option.selected {
background-color: #f5f5f5;
}
.custom-dropdown {
max-height: 256px;
overflow-y: auto;
position: absolute;
top: 141%;
left: -17px;
right: -17px;
width: calc(100% + 35px);
background: #FFFFFF;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
z-index: 10;
border: 1px solid #DADADA;
scrollbar-width: thin;
scrollbar-color: #999 #f1f1f1;
}
.custom-dropdown::-webkit-scrollbar { width: 6px; }
.custom-dropdown::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.custom-dropdown::-webkit-scrollbar-thumb {
background: #999;
border-radius: 3px;
}
.job-board-list {
display: flex;
flex-direction: column;
border-left: 1px solid #DADADA;
border-right: 1px solid #DADADA;
}
.job-board-row {
display: grid;
grid-template-columns: 1fr;
padding: 32px 24px 32px 32px;
border-bottom: 1px solid #DADADA;
background: #FFFFFF;
transition: padding 0.2s ease, background-color 0.2s ease;
text-decoration: none;
cursor: pointer;
}
.job-board-row:hover { padding: 32px; background-color: #F0F8FF; }
.job-board-job-cell { padding: 0; }
.job-board-job-title {
margin: 0;
font-size: 28px;
font-weight: 400;
letter-spacing: -0.01em;
line-height: 120%;
color: #1c1c1c;
}
.job-board-location-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.job-board-job-loc {
margin: 0;
font-size: 18px;
font-weight: 400;
line-height: 150%;
color: #1c1c1c;
}
.job-board-more {
font-size: 18px;
text-decoration: none;
color: #0577FF;
white-space: nowrap;
}
.select-wrapper select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 100%;
height: 100%;
background: transparent;
border: none;
outline: none;
padding: 0;
font-size: 18px;
color: #000000;
cursor: pointer;
font-family: inherit;
}
.select-wrapper select option { color: #000000; }
.select-wrapper select option:first-child { color: #767676; }
.select-icon {
position: absolute;
right: 0px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
@media (max-width: 809px) {
.job-board-filters { grid-template-columns: 1fr; }
.job-board-location-row {
flex-direction: column;
align-items: flex-start;
}
.job-board-more { margin-top: 8px; }
.job-board-job-title { font-size: 22px; }
.dropdown-option { font-size: 24px; padding: 10px 14px; }
.custom-dropdown { max-height: 220px; }
.job-board-row { padding: 32px 16px; }
.job-board-row:hover { padding: 32px 16px; }
}
@media (min-width: 810px) {
.custom-dropdown { left: -18px; }
.job-board-filters { grid-template-columns: 1fr 1fr; }
.job-board-filter-cell { grid-column: auto; }
.job-board-search-icon { right: 24px; }
.job-board-row {
grid-template-columns: 1fr auto;
padding: 32px 24px 32px 32px;
}
.job-board-row:hover { padding: 32px 24px 32px 48px; }
.job-board-filter-cell:not(:first-child) {
border-left-color: #00000000;
border-top-color: #dadada;
}
.job-board-job-title { font-size: 24px; }
.dropdown-option { font-size: 34px; }
}
@media (min-width: 1200px) {
.job-board-job-title { font-size: 28px; }
}
`}
</style>
<div className="job-board-container">
{outdated && (
<div
style={{
display: "flex",
gap: 12,
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
marginBottom: 12,
background: "#FFF6E0",
border: "1px solid #F0C36D",
fontSize: 14,
color: "#5A4300",
}}
>
<span>
Editor only: Jobs Snapshot has {snapshot.length} roles,
Ashby has {liveJobs.length}. Copy the latest, paste into
"Jobs Snapshot", then publish so AI crawlers see it.
</span>
<button
onClick={copyLatest}
style={{
padding: "6px 12px",
border: "1px solid #5A4300",
background: "#FFFFFF",
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
{copied ? "Copied" : "Copy latest jobs"}
</button>
</div>
)}
{/* Filters */}
<div className="job-board-filters">
<div className="job-board-filter-cell">
<input
type="text"
placeholder={placeholderSearch}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="job-board-search"
/>
<span className="job-board-search-icon">
<svg
width="18"
height="19"
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.5482 17.2554L12.0549 11.7339C12.9899 10.5591 13.5743 9.09066 13.5743 7.44597C13.5743 3.74543 10.5939 0.749756 6.91214 0.749756C3.23042 0.749756 0.25 3.74543 0.25 7.44597C0.25 11.1465 3.23042 14.1422 6.91214 14.1422C8.49002 14.1422 9.95105 13.6136 11.0614 12.6737L16.6132 18.2539L17.5482 17.2554ZM6.91214 12.7325C3.99014 12.7325 1.59411 10.3242 1.59411 7.38725C1.59411 4.45031 3.99014 2.04202 6.91214 2.04202C9.83415 2.04202 12.2302 4.45031 12.2302 7.38725C12.2302 10.3242 9.83415 12.7325 6.91214 12.7325Z"
fill="#1C1C1C"
/>
</svg>
</span>
</div>
<div className="job-board-filter-cell">
<DropdownSearch
options={locs}
value={locFilter}
onChange={setLocFilter}
placeholder={placeholderLoc}
font={font}
/>
</div>
</div>
{/* Job list */}
<div
className="job-board-list"
data-ashby-rows=""
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: jobRowsHtml(filtered) }}
/>
</div>
</>
)
}
ResponsiveJobBoard.defaultProps = {
jobBoardName: "",
jobsSnapshot: "",
placeholderSearch: "Search",
placeholderLoc: "Location",
}
ResponsiveJobBoard.propertyControls = {
jobBoardName: {
type: ControlType.String,
title: "Job Board Name",
placeholder: "e.g. Acme",
},
jobsSnapshot: {
type: ControlType.String,
title: "Jobs Snapshot",
displayTextArea: true,
placeholder: "Paste JSON from the Ashby API (or 'Copy latest jobs')",
},
placeholderSearch: {
type: ControlType.String,
title: "Search Placeholder",
defaultValue: "Search",
},
placeholderLoc: {
type: ControlType.String,
title: "Loc Placeholder",
defaultValue: "Location",
},
font: {
type: ControlType.Font,
controls: "extended",
},
}
Automatic route only. Runs every 6 hours on Cloudflare's free plan.
// Cloudflare Worker: every 6 hours, copies the live Ashby jobs into the
// "Jobs Snapshot" field of every ResponsiveJobBoard in the Framer project.
// It NEVER publishes. The next normal Framer publish bakes the latest jobs
// into the HTML that AI crawlers read. Visitors always see live jobs.
//
// Settings (Cloudflare):
// FRAMER_PROJECT_URL plain variable (wrangler.toml)
// FRAMER_API_KEY secret: npx wrangler secret put FRAMER_API_KEY
import { connect } from "framer-api"
async function syncJobBoards(framer, snapshots, log) {
const boards = (await framer.getNodesWithType("ComponentInstanceNode")).filter(
(n) => n.componentName === "ResponsiveJobBoard"
)
if (boards.length === 0) throw new Error("No ResponsiveJobBoard found in the project")
let updated = 0
for (const board of boards) {
const name = board.controls?.jobBoardName
const snapshot = snapshots.get(name)
if (!snapshot) {
log(`Skip ${board.id}: no jobs for "${name}"`)
continue
}
if (board.controls?.jobsSnapshot === snapshot) continue
await board.setAttributes({ controls: { jobsSnapshot: snapshot } })
updated++
log(`Updated "${name}"`)
}
return updated
}
async function fetchListedJobs(name) {
const res = await fetch(
`https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(name)}`
)
if (!res.ok) throw new Error(`Ashby API ${res.status} for "${name}"`)
const data = await res.json()
return (data.jobs || [])
.filter((j) => j.isListed !== false)
.map((j) => ({
title: j.title,
location: j.location || "Remote",
applyUrl: j.applyUrl || j.jobUrl || "#",
isListed: true,
}))
}
async function sha(text) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text))
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("")
}
// The free plan allows ~10 ms of CPU per run. Connecting to Framer is the
// expensive part, so it only happens when Ashby's jobs differ from the last
// successful sync (hash kept in KV). A failed run leaves the hash unchanged,
// so the retry cron tries again.
async function run(env) {
if (!env.FRAMER_API_KEY) {
console.log("FRAMER_API_KEY secret is not set yet; skipping.")
return
}
const boardNames = (env.JOB_BOARD_NAMES || "").split(",").map((s) => s.trim()).filter(Boolean)
const snapshots = new Map()
for (const name of boardNames) {
const listed = await fetchListedJobs(name)
// An Ashby hiccup returning nothing shouldn't wipe the crawler copy.
if (listed.length === 0) {
console.log(`Ashby returned 0 roles for "${name}"; skipping it.`)
continue
}
// Same format as the component's "Copy latest jobs" button.
snapshots.set(name, JSON.stringify({ jobs: listed }))
}
if (snapshots.size === 0) return
const hash = await sha([...snapshots.entries()].sort().join("|"))
if ((await env.JOBS_STATE.get("lastSyncedHash")) === hash) {
console.log("No change since the last sync; Framer not contacted.")
return
}
const framer = await connect(env.FRAMER_PROJECT_URL, env.FRAMER_API_KEY)
try {
const n = await syncJobBoards(framer, snapshots, console.log)
await env.JOBS_STATE.put("lastSyncedHash", hash)
console.log(n ? `Done. ${n} board(s) updated; live after the next publish.` : "Done. Already up to date.")
} finally {
await framer.disconnect()
}
}
export default {
async scheduled(_event, env, ctx) {
ctx.waitUntil(run(env))
},
}
// Exported for the local smoke test only.
export { fetchListedJobs }
Replace the two PASTE_ values (Framer project link, KV id from the README steps).
name = "ashby-jobs-framer-sync"
main = "src/index.js"
compatibility_date = "2026-10-01"
compatibility_flags = ["nodejs_compat"]
# No public URL: this Worker only runs on its schedule.
workers_dev = false
[triggers]
# Every 6 hours, plus a retry 30 min later. The retry costs almost nothing:
# if the first run succeeded, nothing changed and Framer is not contacted.
crons = ["0 */6 * * *", "30 */6 * * *"]
[[kv_namespaces]]
binding = "JOBS_STATE"
id = "PASTE_KV_ID_HERE"
[vars]
JOB_BOARD_NAMES = "possible-finance" # comma-separated Ashby board names
FRAMER_PROJECT_URL = "PASTE_FRAMER_PROJECT_LINK_HERE"
[observability]
enabled = true
{
"name": "ashby-jobs-framer-sync",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev --test-scheduled",
"deploy": "wrangler deploy"
},
"dependencies": {
"framer-api": "5.1.0"
},
"devDependencies": {
"wrangler": "4.151.1"
}
}
# Ashby → Framer sync (Cloudflare Worker)
Every 6 hours, this Worker copies the live Ashby jobs into the **Jobs Snapshot** setting of every `ResponsiveJobBoard` in the Framer project. It only contacts Framer when the jobs changed, which keeps each run inside the Cloudflare free plan's 10 ms CPU limit.
It never publishes. The next normal Framer publish puts the latest jobs into the HTML that AI crawlers read.
Full background and the client options are in `../HANDOFF.md`.
## Setup (about 15 minutes)
Needs Node 22 or later and a Cloudflare account. The free plan is enough, and the website doesn't need to be on Cloudflare.
1. `npm install`
2. `npx wrangler login`
3. `npx wrangler kv namespace create JOBS_STATE`, then paste the printed `id` into the `[[kv_namespaces]]` block in `wrangler.toml`. KV is free.
4. In `wrangler.toml`, set `FRAMER_PROJECT_URL` to the Framer project link and `JOB_BOARD_NAMES` to the Ashby board name.
5. `npx wrangler deploy`
6. `npx wrangler secret put FRAMER_API_KEY`, then paste the key when asked.
- Create the key in Framer under Site Settings → General → API keys.
- Never commit the key.
In Framer, the job board's **Job Board Name** must be the Ashby board name: `possible-finance`.
## Run it now (instead of waiting)
In `wrangler.toml`, set `crons = ["* * * * *"]` and run `npx wrangler deploy`. Wait a minute or two, then set it back to `["0 */6 * * *", "30 */6 * * *"]` and deploy again. The second cron is an automatic retry: it does nothing if the first run succeeded.
## Logs
Cloudflare dashboard → Workers & Pages → `ashby-jobs-framer-sync` → Logs.
| Log line | Meaning |
|---|---|
| `Updated "possible-finance"` | The snapshot changed |
| `No change since the last sync; Framer not contacted.` | Nothing changed (the normal case) |
| `Ashby returned 0 roles …` | Ashby returned nothing, so the snapshot was kept |
| `FRAMER_API_KEY secret is not set yet; skipping.` | The key hasn't been added (step 5) |
## Remove
`npx wrangler delete`, then revoke the key in Framer.
How the hybrid works and why.
# Careers jobs visible to AI crawlers: handoff
## The problem
The job list on /careers and /careers/available-jobs is loaded from Ashby in the visitor's browser. The crawlers of OpenAI, Anthropic and Perplexity don't run JavaScript, so they see the careers pages with no jobs on them.
## The fix
**1. Hybrid job board (Framer code component).** `ResponsiveJobBoard-hybrid-framer-tested.tsx` replaces the current job board. It keeps the same design, filters and apply links.
- **Visitors** see the live Ashby list, fetched in the browser as it is today.
- **Crawlers** see a copy of the jobs that Framer writes into the page HTML when the site is published. The copy comes from the component's **Jobs Snapshot** setting.
**2. Sync every 6 hours (Cloudflare Worker).** `cloudflare-sync/` keeps Jobs Snapshot up to date.
- Every 6 hours it reads Ashby's public job board and writes the current roles into Jobs Snapshot through Framer's Server API.
- It **never publishes.** The next normal publish puts the latest roles into the HTML.
- If Ashby returns no roles (for example, during an outage), it leaves the snapshot alone.
```
Ashby ──every 6h──> Cloudflare Worker ──Server API──> Framer "Jobs Snapshot"
│
(you publish as usual)
▼
Page HTML that crawlers read
```
**Tested on Oct 18, 2026 on a Framer test project:**
- I emptied the snapshot. The deployed Worker restored all 8 roles within a minute, with no errors.
- Most runs only read Ashby and skip Framer, so they use a few ms of CPU.
## Who does what
| Task | Who |
|---|---|
| Add the component in Framer and set Job Board Name to `possible-finance` | Sam (has Framer access) |
| Create the Framer API key (Site Settings → General → API keys) | Sam |
| Give access to a Cloudflare account (Option 1), **or** run the setup with AI (Option 2) | Client |
| Publish the site as usual | Client or Sam |
Ashby needs nothing: its job board API is public, and the board name is `possible-finance`.
## What we need from the client: pick one option
### Option 1 (easiest): invite Sam to a Cloudflare account. Free, about 10 minutes.
1. Log in to Cloudflare, or sign up for free at dash.cloudflare.com/sign-up.
- A company account is better than a personal one, so it stays with the company.
- The website does **not** need to be on Cloudflare. Nothing changes on the domain or DNS.
2. Go to **Manage Account → Members → Invite**.
- Enter Sam's email.
- Choose the **Administrator** role, or **Workers Admin** if you want to limit access to Workers.
- Only a Super Administrator can send invites.
3. Tell Sam it's done. Sam deploys the Worker and adds the key, which takes about 15 minutes.
After setup, you can remove Sam's access. The Worker keeps running.
### Option 2: your developer sets it up with Claude (or any AI coding tool). About 30 minutes.
Sam sends:
- `cloudflare-sync.zip`, the Worker code
- the Framer API key, through a password manager share (not email or chat)
Your developer:
1. Unzips the folder and opens it in Claude Code, Cursor or VS Code.
2. Gives the AI this prompt:
> This folder is a Cloudflare Worker that syncs Ashby jobs into a Framer project every 6 hours. Read README.md, then:
> 1. Run `npm install`.
> 2. Run `npx wrangler login` and wait for me to log in to our Cloudflare account in the browser.
> 3. In wrangler.toml, set FRAMER_PROJECT_URL to `<Framer project link from Sam>`.
> 4. Run `npx wrangler deploy`.
> 5. Run `npx wrangler secret put FRAMER_API_KEY` and tell me to paste the key myself. Never print the key, write it to a file or commit it.
> 6. Show me the deployed Worker name and its cron schedule.
3. Tells Sam it's done. Sam checks that the snapshot updates.
### Option 3 (fallback): Sam hosts it
The Worker runs on Sam's own Cloudflare account, and the client does nothing. The downside is that it depends on Sam's account, so when the engagement ends it should move to the client using Option 1 or 2.
## Things to know
- **Cost:** $0.
- Cloudflare Workers Free includes cron triggers.
- Framer's Server API is free while it's in beta; Framer has said usage pricing will come later.
- **Stays inside the free CPU limit (10 ms per run):**
- The Worker first reads Ashby (cheap) and compares the roles with a hash saved in Workers KV (free).
- It connects to Framer, the only heavy step, only when the roles changed. Most runs are "No change" and use a few ms.
- The hash is saved only after Framer accepts the write. A second cron 30 minutes later retries automatically if a run fails.
- Free-plan use: 2 of 5 cron triggers, about 8 runs a day, a handful of KV operations. All far below the free limits.
- If **Exceeded CPU Time Limits** ever shows up repeatedly under Worker → Metrics, the manual fallback below still works. No paid plan is required.
- **The API key acts as the person who created it.** If that person loses access to the Framer project, the key stops working. If Sam created it, recreate it under a client Framer admin when Sam's access ends. Then update the Worker with `npx wrangler secret put FRAMER_API_KEY`.
- **New roles reach crawlers only after a publish.** Visitors always see live roles. Crawlers see the roles as of the last publish, at most 6 hours older than the publish. If the site goes weeks without a publish, crawlers' copy will be weeks old.
- **Manual fallback:** select the job board in Framer, click **Copy latest jobs**, paste into Jobs Snapshot, then publish.
- **Logs:** Cloudflare dashboard → Workers & Pages → `ashby-jobs-framer-sync` → Logs.
- **To remove it:** `npx wrangler delete`, and revoke the key in Framer.
## Check it worked
1. In Framer, select the job board. Jobs Snapshot should contain JSON with the current roles within 6 hours of setup (or run the Worker once by hand).
2. Publish, then run:
`curl -A GPTBot https://www.possiblefinance.com/careers/available-jobs`
Every role title should appear in the output.
## Files
| File | What it is |
|---|---|
| `ResponsiveJobBoard-hybrid-framer-tested.tsx` | The Framer code component (Sam pastes it in) |
| `cloudflare-sync/src/index.js` | The Worker |
| `cloudflare-sync/wrangler.toml` | Worker settings: cron schedule, KV binding, Ashby board name and the Framer project link |
| `cloudflare-sync/README.md` | Short setup reference |
| `cloudflare-sync.zip` | The folder to send for Option 2 |
| `archive/SETUP-edge-proxy-not-used.md` | An earlier approach (rewriting pages at the edge). **Not used.** |