Possible Finance careers: auto-sync handoff

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 byOleg repo + invite + secret (5 min). Sam the rest.Possible dev team (about 30 min). Sam does Framer only.
Messages back and forthTwo: “repo done”, “it works”One: “done”
Auto-publishOptional switch (off by default)No, publish as usual
CostFreeFree plan
API key: Sam creates it in Framer and sends it by personal Slack DM to whoever sets up the chosen path. Never in a channel, email or code.

Step 0: Sam, in Framer (both paths)

  1. Sam In Framer: Assets > Code > + > New Component. Empty the new file completely, paste File 1 once, save.
  2. Sam Drag the component from Assets > Code onto the Careers page, replacing the old job board. If an old instance shows “Component file does not exist”, delete it and insert a new one.
  3. Sam Set Job Board Name to possible-finance. Leave Jobs Snapshot empty. The auto-sync fills it.
  4. Sam Create a Framer API key: Site Settings > General > API keys. Sam sends it by personal Slack DM (never a channel or email) to whoever sets up the chosen path: Oleg for GitHub, the Possible dev team for Cloudflare. Also send the Framer project link (the URL of the project).

Component code

Framer component (paste into Framer)

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, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
}

// 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",
    },
})

Path A: GitHub

Oleg makes a private repo, invites Sam, adds one secret. Sam does everything else. Free. Can optionally auto-publish.

Oleg does this (about 5 minutes)

Shortcut: copy this prompt, paste it into Claude (with terminal access) and it does steps 1 to 3 for you. The key is typed into a hidden prompt, never into the chat.
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:

  1. Oleg On GitHub, create a new private repository, for example careers-jobs-sync. Tick “Add a README”.
  2. Oleg Settings > Collaborators > Add people. Invite samframer1-tech (sam.framer1@gmail.com) with the Write role.
  3. Oleg Settings > Secrets and variables > Actions > New repository secret. Name: FRAMER_API_KEY. Value: the key Sam sent on Slack. (Only an admin can add secrets, so Sam cannot do this one.)
  4. Oleg Tell Sam: “repo, invite and secret are done”.

Sam does this

  1. Sam Accept the invite. Open the repo, then Add file > Create new file.
  2. Sam Create sync-ashby-snapshot.mjs in the repo root and paste File 2. Commit.
  3. Sam Create .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.
  4. Sam Open the Actions tab. If GitHub asks, click “I understand my workflows, enable them”. Choose “Sync Ashby jobs into Framer” > Run workflow.
  5. Sam Open the finished run and the “node sync-ashby-snapshot.mjs” step. You should see Updated "possible-finance": N roles or No change, then Done. Green tick means it works.
  6. Sam In Framer, click the component: Jobs Snapshot now holds the jobs. Publish the site once.
  7. Sam Optional, fully automatic: in the workflow file change 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.
Good to know: keep the repo private. GitHub pauses scheduled runs on public repos after 60 days of no activity. In a private repo, if runs ever pause, open Actions and click Run workflow once. Scheduled runs can start 5 to 30 minutes late.

GitHub code

sync-ashby-snapshot.mjs

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()
}

.github/workflows/sync-ashby-jobs.yml

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 change

Path B: Cloudflare (Possible dev team)

Purpose. 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.

Setup

  1. Node 22+ and a Cloudflare account.
  2. Create the project with the three files below:
    ashby-jobs-sync/
      package.json
      wrangler.toml
      src/index.js
  3. npm install
  4. npx wrangler login
  5. npx wrangler kv namespace create JOBS_STATE, then put the printed id in wrangler.toml (PASTE_KV_ID_HERE).
  6. In wrangler.toml set the Framer project URL (PASTE_FRAMER_PROJECT_LINK_HERE). Leave JOB_BOARD_NAMES = "possible-finance".
  7. npx wrangler deploy
  8. npx wrangler secret put FRAMER_API_KEY (paste at the prompt; do not commit it).
  9. Verify: 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.
  10. Tell Sam it is done. Sam checks the snapshot in Framer and publishes.

Key rotation: Sam sends a new key; repeat step 8.

Cloudflare code

src/index.js

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 }

wrangler.toml

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

package.json

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"
  }
}

How to tell it works