Goal: AI crawlers (ChatGPT, Claude, Perplexity) can read the open jobs on the careers page, and the list stays up to date by itself. Pick one path.
| Path A: GitHub (recommended) | Path B: Cloudflare | |
|---|---|---|
| Setup by | Oleg repo + invite + secret (5 min). Sam the rest. | Possible dev team (about 30 min). Sam does Framer only. |
| Messages back and forth | Two: “repo done”, “it works” | One: “done” |
| Auto-publish | Optional switch (off by default) | No, publish as usual |
| Cost | Free | Free plan |
possible-finance. Leave Jobs Snapshot empty. The auto-sync fills it.File name: ResponsiveJobBoard.tsx (any file name works) · used by: Sam
// @ts-nocheck
/**
* For any website & code related support, contact Sam at https://builtforsaas.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. A scheduled sync
* (GitHub Actions or a Cloudflare Worker) keeps "Jobs Snapshot" current.
*
* Response shape:
* { apiVersion: "1", jobs: [ { title, location, applyUrl, jobUrl,
* isListed, isRemote, workplaceType, department, team, employmentType,
* secondaryLocations: [...], ... } ] }
*/
import { addPropertyControls, 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 default 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)
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])
// 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">
{/* 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>
</>
)
}
addPropertyControls(ResponsiveJobBoard, {
jobBoardName: {
type: ControlType.String,
title: "Job Board Name",
placeholder: "e.g. Acme",
},
jobsSnapshot: {
type: ControlType.String,
title: "Jobs Snapshot",
displayTextArea: true,
placeholder: "Filled automatically by the scheduled sync",
},
placeholderSearch: {
type: ControlType.String,
title: "Search Placeholder",
defaultValue: "Search",
},
placeholderLoc: {
type: ControlType.String,
title: "Loc Placeholder",
defaultValue: "Location",
},
font: {
type: ControlType.Font,
controls: "extended",
},
})
Oleg makes a private repo, invites Sam, adds one secret. Sam does everything else. Free. Can optionally auto-publish.
I need to set up a private GitHub repo and give a freelancer (Sam) access. Use the GitHub CLI (gh) on my machine. If gh is not installed or not logged in, help me do that first (gh auth login). Do these steps one at a time and confirm each result before moving on. 1. Create a PRIVATE repository named careers-jobs-sync (with a README) under my account or my company's organization. Ask me which owner to use if it is not obvious. gh repo create OWNER/careers-jobs-sync --private --add-readme 2. Invite the GitHub user samframer1-tech (account email: sam.framer1@gmail.com) as a collaborator with Write access. gh api -X PUT repos/OWNER/careers-jobs-sync/collaborators/samframer1-tech -f permission=push 3. Add a repository secret named FRAMER_API_KEY. Run the command below and let me paste the key at its hidden prompt myself. gh secret set FRAMER_API_KEY --repo OWNER/careers-jobs-sync Never ask me to paste the key into this chat, never print it, and never write it to a file. If you cannot run an interactive command, tell me the exact command and I will run it myself in my own terminal. 4. Verify and show me the results: the pending invitation (gh api repos/OWNER/careers-jobs-sync/invitations), the secret name (gh secret list --repo OWNER/careers-jobs-sync), and the repo URL. When everything is verified, tell me to message Sam: "repo, invite and secret are done".
Or by hand:
careers-jobs-sync. Tick “Add a README”.samframer1-tech (sam.framer1@gmail.com) with the Write role.FRAMER_API_KEY. Value: the key Sam sent on Slack. (Only an admin can add secrets, so Sam cannot do this one.)sync-ashby-snapshot.mjs in the repo root and paste File 2. Commit..github/workflows/sync-ashby-jobs.yml (typing the slashes creates the folders) and paste File 3. Replace PASTE_FRAMER_PROJECT_LINK_HERE with the Framer project link. Commit.Updated "possible-finance": N roles or No change, then Done. Green tick means it works.AUTO_PUBLISH: "false" to "true". Then every time the jobs change, the site is also published with no one clicking anything. Warning: it publishes the whole site, including any unfinished edits in the Framer project. Leave it off if the team edits the live project often.File name: sync-ashby-snapshot.mjs (repo root) · used by: Sam
// Keeps the AI-crawler copy of the Careers jobs up to date, with no copy and paste.
//
// Every run it reads the live jobs from Ashby and writes them into the
// "Jobs Snapshot" field of every ResponsiveJobBoard on the Framer site.
// It NEVER publishes, so half-finished edits in Framer never go live because of it.
// Whenever anyone publishes the site as normal, the HTML that AI crawlers read
// already has the latest jobs (at most one run old). Visitors always see live jobs.
//
// Env: FRAMER_PROJECT_URL, FRAMER_API_KEY (Framer > Site Settings > General > API)
// AUTO_PUBLISH=true (optional) also publishes the site when the jobs changed.
import { connect } from "framer-api"
// --- sync logic start ---
async function syncJobBoards(framer, log = console.log) {
const boards = (await framer.getNodesWithType("ComponentInstanceNode")).filter(
(n) => n.controls && "jobsSnapshot" in n.controls
)
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
if (!name) {
log(`Skip ${board.id}: Job Board Name is empty`)
continue
}
const res = await fetch(
`https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(name)}?includeCompensation=true`
)
if (!res.ok) throw new Error(`Ashby API ${res.status} for "${name}"`)
const data = await res.json()
const listed = (data.jobs || []).filter((j) => j.isListed !== false)
// An Ashby hiccup returning nothing shouldn't wipe the crawler copy.
if (listed.length === 0) {
log(`Skip ${board.id}: Ashby returned 0 roles for "${name}"`)
continue
}
// Compact format the component reads from Jobs Snapshot.
const snapshot = JSON.stringify({
jobs: listed.map((j) => ({
title: j.title,
location: j.location || "Remote",
applyUrl: j.applyUrl || j.jobUrl || "#",
isListed: true,
})),
})
if (board.controls?.jobsSnapshot === snapshot) {
log(`No change for "${name}" (${listed.length} roles)`)
continue
}
await board.setAttributes({ controls: { jobsSnapshot: snapshot } })
updated++
log(`Updated "${name}": ${listed.length} roles -> ${listed.map((j) => j.title).join(" | ")}`)
}
return updated
}
// --- sync logic end ---
const framer = await connect(process.env.FRAMER_PROJECT_URL, process.env.FRAMER_API_KEY)
try {
const n = await syncJobBoards(framer)
if (n && process.env.AUTO_PUBLISH === "true") {
const { deployment } = await framer.publish()
await framer.deploy(deployment.id)
console.log("Published the site.")
}
console.log(n ? `Done. ${n} board(s) updated.` : "Done. Already up to date.")
} finally {
await framer.disconnect()
}
File name: .github/workflows/sync-ashby-jobs.yml · used by: Sam
# Put this file at .github/workflows/sync-ashby-jobs.yml in a private GitHub repo,
# with sync-ashby-snapshot.mjs at the repo root.
# One repo secret is needed: FRAMER_API_KEY (Settings > Secrets and variables > Actions).
name: Sync Ashby jobs into Framer
on:
schedule:
- cron: "0 */6 * * *" # every 6 hours (GitHub may start it 5-30 min late)
workflow_dispatch: {} # the "Run workflow" button, for a manual run
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm i framer-api@5.1.0
- run: node sync-ashby-snapshot.mjs
env:
FRAMER_PROJECT_URL: PASTE_FRAMER_PROJECT_LINK_HERE
FRAMER_API_KEY: ${{ secrets.FRAMER_API_KEY }}
AUTO_PUBLISH: "false" # "true" = also publish the site when jobs changePurpose. The careers job list renders client-side from Ashby, so crawlers see no jobs. This Worker copies the open jobs into the Framer component's Jobs Snapshot field every 6 hours; Framer writes them into the published HTML. Visitors still get live jobs.
Behaviour. Cron 0 */6 * * * plus a retry at 30 */6 * * *. Reads the public Ashby board possible-finance, hashes the result in KV, and calls the Framer Server API only when it changed. Never publishes. No routes, no DNS or domain changes. Free plan is sufficient.
Inputs from Sam (Slack DM): Framer project URL and Framer API key. The code below is complete; only the three placeholders need filling.
ashby-jobs-sync/ package.json wrangler.toml src/index.js
npm installnpx wrangler loginnpx wrangler kv namespace create JOBS_STATE, then put the printed id in wrangler.toml (PASTE_KV_ID_HERE).wrangler.toml set the Framer project URL (PASTE_FRAMER_PROJECT_LINK_HERE). Leave JOB_BOARD_NAMES = "possible-finance".npx wrangler deploynpx wrangler secret put FRAMER_API_KEY (paste at the prompt; do not commit it).npx wrangler tail and trigger a run (temporarily set crons = ["* * * * *"], deploy, then restore and redeploy). Expect Updated "possible-finance" or No change since the last sync.Key rotation: Sam sends a new key; repeat step 8.
File name: src/index.js · used by: Possible dev team
// 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.controls && "jobsSnapshot" in n.controls
)
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
}
// Compact format the component reads from Jobs Snapshot.
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 }
File name: wrangler.toml · used by: Possible dev team
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
File name: package.json · used by: Possible dev team
{
"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"
}
}