Building Pages.

Every page on internal.perkup.com is built the same way so the whole wiki feels like one product. This page is the contract — read it before you ship anything.

At a glance

internal.perkup.com is a Cloudflare Pages project (internal-docs) gated by Cloudflare Access to @perkupapp.com Workspace identity. Humans and their agents authenticate the same way — Google Workspace SSO. Pages must follow the shared design system + registration contract below.

How agents fetch this wiki

Every page is behind Cloudflare Access. A plain WebFetch or curl returns a 302 to the SSO login page. To read the wiki, agents use the official cloudflared CLI which performs the same SSO flow you'd do in a browser — but stores a short-lived per-user token on your machine that automatically gets attached to subsequent requests.

One-time install

brew install cloudflared

Sign in (once per ~24h)

cloudflared access login https://internal.perkup.com/

Opens your browser, walks through the same Google Workspace SSO flow that a human goes through. After signing in, cloudflared stores a token under ~/.cloudflared/ tied to your identity. Token lasts ~24h before you need to repeat.

Fetch a page

cloudflared access curl https://internal.perkup.com/agents/

Drop-in replacement for curl. Same flags, same output, same behavior — but it injects the stored auth token so the request gets through Access.

Use the token directly

TOKEN=$(cloudflared access token --app=https://internal.perkup.com/) && \
  curl -sS -H "cf-access-token: $TOKEN" https://internal.perkup.com/agents/

If your tooling needs a raw HTTP header instead of the curl wrapper, this returns the JWT. Pass it as the cf-access-token header on any request.

  • Every fetch is attributed to your Workspace identity in Cloudflare's audit logs — there's no shared bearer secret floating around.
  • If your laptop is lost, revoking your Workspace account immediately revokes wiki access.
  • If the token expires mid-task, the next fetch will surface a 302 — just re-run cloudflared access login in another terminal and retry.

Shared design system

Every page imports two stylesheets — never inline colors, fonts, or spacing values. These files are the single source of truth.

  • /_assets/wiki.css — design tokens (color, type, spacing, radius) + all wiki-* components (.wiki-header, .wiki-footer, .wiki-hero, .wiki-card-grid, .wiki-callout, .wiki-table, .wiki-code, .wiki-pill)
  • Color tokens are CSS custom properties on :root with a :root[data-theme="light"] override. Reference them as var(--color-accent), never raw hex.
  • Font stack: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif. System fonts only — never web fonts.
  • Icons: Lucide outline set, inline SVG with stroke="currentColor", stroke-width="2", viewBox="0 0 24 24". Never use icon webfonts.
  • If you need a new component, propose it back to the team — don't invent one. Section-agents do not modify wiki.css.

The page template

Every page extends this skeleton. The four wiki-* meta tags are mandatory — that is how other agents crawl and index the wiki efficiently.

<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{Page Title} · PerkUp Internal</title>

  <link rel="icon" type="image/png" href="/_assets/favicon-dark.png" media="(prefers-color-scheme: dark)">
  <link rel="icon" type="image/png" href="/_assets/favicon-light.png" media="(prefers-color-scheme: light)">
  <link rel="apple-touch-icon" href="/_assets/favicon-dark.png">

  <!-- REQUIRED: 4 machine-readable wiki tags -->
  <meta name="wiki-section" content="{section-slug}">
  <meta name="wiki-page" content="{page-slug}">
  <meta name="wiki-last-updated" content="YYYY-MM-DD">
  <meta name="wiki-summary" content="{one-line summary, ≤ 200 chars}">

  <meta property="og:title" content="{Page Title}">
  <meta property="og:description" content="{summary}">

  <!-- Pre-paint theme bootstrap to avoid flash -->
  <script>(function(){var s=null;try{s=localStorage.getItem('perkup-theme');}catch(e){}var m=window.matchMedia&&window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',s||m);})();</script>

  <link rel="stylesheet" href="/_assets/wiki.css">
</head>
<body>
  <!-- shared topnav (copy verbatim from this page or any other) -->
  <nav class="topnav">...</nav>

  <div class="layout">
    <main class="main">
      <h1>{Page Title}</h1>
      <p class="lede">{One-paragraph lede.}</p>
      <!-- content -->
    </main>
    <aside class="toc">...</aside>
  </div>

  <footer class="site-footer">...</footer>
</body>
</html>

Register your page

When you ship a new page, append entries to four registries. This is what makes the homepage's "Published Pages" list, the sitemap, and the agent-crawl indexes stay coherent.

  1. /_meta/nav.json — append to the matching section's pages array: {slug, title, url, summary}.
  2. /_meta/pages.json — append full per-page metadata: {slug, title, section, url, summary, last_updated, source}.
  3. /sitemap.xml — append a <url> entry with <loc> and <lastmod>.
  4. /llms.txt — append a line under ## Recent: - [Title](/section/page/) — summary.

Naming & URL conventions

  • URLs are kebab-case (e.g. /customers/case-studies/, not /customers/case_studies/).
  • Section names are plural (/customers/, not /customer/).
  • Each page is a directory with index.html — never bare .html files at section root.
  • File title tag pattern: {Page Title} · PerkUp Internal
  • Every page opens with <h1> matching the title, then a one-paragraph lede, then content, then a "Source" footnote.

Content age rules

Stale content erodes trust. Before pulling source content onto a page, check when it was last modified.

  • Under 6 months — include verbatim (reformat to wiki style).
  • 6 to 18 months — include with a Last verified: YYYY-MM-DD note at the top of the page.
  • Over 18 months AND not marked "Use as-is" or "load-bearing" in the planning sheet's Source Inventory tab → skip. Log every skip in your return report.

Where source content lives

  • Wiki Build Plan — the master inventory. Tab 1 (IA), Tab 4 (Source Inventory) tell you what goes where.
  • Outline → Notion Migration — 52 docs already migrated; URLs in column N point at the Notion target.
  • Notion — primary source for Company, Operations, People, Brand. Use the mcp__claude_ai_Notion__* tools.
  • Google Drive — primary source for sales decks, case studies, RFP responses. Use the gws CLI, not the google-drive MCP.
  • Figma — brand assets and mockups. URLs come from the planning sheet.
  • perkup-app monorepo — primary source for Engineering pages (AGENTS.md files across every directory).

Deploy

A single wrangler command publishes any folder of HTML to internal.perkup.com behind the Access gate.

cd /tmp/internal_wiki && \
  source ~/.claude/.api-keys/cloudflare && \
  CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID \
  CLOUDFLARE_API_TOKEN=$CLOUDFLARE_API_TOKEN \
  npx --yes wrangler@latest pages deploy . \
    --project-name=internal-docs \
    --branch=main \
    --commit-dirty=true

Or in Claude Code, invoke the /publish-internal skill with a path. It wraps the same command, validates the page structure, and updates the registries for you.

Pre-deploy checklist

  • Imports /_assets/wiki.css
  • 4 wiki-* meta tags filled in (section, page, last-updated, summary)
  • OG title + description set
  • Page opens with <h1> matching <title>, then a one-paragraph lede
  • Uses only wiki-* components (no custom CSS in the page)
  • Ends with a "Source" footnote listing every URL the page was built from
  • Updated all four registries (nav.json, pages.json, sitemap.xml, llms.txt)
  • Verified vs the hiring page — does it look like a sibling?

Source: this page is the contract version of the conventions in /_meta/build-rules.md (markdown form for direct agent consumption) and the planning sheet's tabs 1-6.