ctipilot.ch

Private deployment

The stack runs unchanged as an organization-internal service: private GitHub repository, the same routines and publishing chain, and the reader site served by your own web server instead of GitHub Pages. This is the supported mode when the intelligence you handle — including anything dropped under intel/ — should not be world-readable. The pipeline itself has no TLP or visibility gate: it processes everything it can read, so keeping material non-public is a function of the repo being private and the site being internally hosted, not of any config flag.

What changes, what doesn't

Piece Public (default) Private
Repository public private (routines, auto-merge, compose workflows all work identically)
config/org-profile.yaml deployment.site_url https://ctipilot.ch/ your internal URL, or "" to skip site polling
Site hosting GitHub Pages via deploy-site.yml internal web server pulling + building on a schedule
TLP / visibility gate none (removed) none (removed) — privacy comes from the private repo + internal hosting
Routine phases identical identical — only Phase 7's site poll adapts to site_url

site/build.py is stdlib-only and emits a fully static bundle into site/_site/ — no server-side code, so any static web server can host it. All assets are vendored (strict CSP, no external requests), which means the site works on an air-gapped network segment.

Step-by-step

  1. Make the repository private (or create a private fork/mirror your routines run against). Verify the scheduled Claude Code routines and the GitHub App still have access; auto-merge-claude.yml, compose-profile.yml, and source-health.yml keep working — Actions run fine on private repos (minutes are metered).
  2. Disable GitHub Pages publishing: either disable Pages in the repo settings, or delete/disable .github/workflows/deploy-site.yml (it is the only Pages-specific piece; nothing else references it).
  3. Point the deployment at your host and compose:
   # config/org-profile.yaml
   # deployment:
   #   site_url: "https://cti.intra.example.ch/"   # or "" to skip Phase 7 site polling
   python3 tools/compose_prompts.py --write && git add config prompts .claude && git commit

There is no visibility key — the pipeline processes everything it can read regardless; privacy is enforced by the private repo and internal hosting, not a config flag. An empty site_url makes the routine's Phase 7 report publish: ok (main — site polling disabled) after the auto-merge lands, instead of polling a site it cannot reach.

  1. Host the site internally — scheduled pull + build + serve. On the internal web host:
   # /opt/ctipilot/refresh.sh — pull main, rebuild, atomically swap the webroot
   set -euo pipefail
   cd /opt/ctipilot/repo                       # git clone <repo-url> repo  (once)
   git fetch origin main && git reset --hard origin/main
   python3 site/build.py                        # writes site/_site/
   rsync -a --delete site/_site/ /var/www/ctipilot/

Schedule it (systemd timer shown; cron works the same):

   # /etc/systemd/system/ctipilot-refresh.service
   [Service]
   Type=oneshot
   User=ctipilot
   ExecStart=/opt/ctipilot/refresh.sh

   # /etc/systemd/system/ctipilot-refresh.timer
   [Timer]
   OnCalendar=*:0/15          # every 15 min; entries land throughout the day
   Persistent=true
   [Install]
   WantedBy=timers.target

Serve /var/www/ctipilot/ with any static server. nginx example:

   server {
     listen 443 ssl;
     server_name cti.intra.example.ch;
     root /var/www/ctipilot;
     index index.html;
     # Mirror the Pages behaviour: extensionless pretty URLs resolve to dirs.
     location / { try_files $uri $uri/ $uri/index.html =404; }
   }

Quick local preview without a real server: python3 -m http.server --directory site/_site 8080.

  1. Access control is the web server's job (VPN-only vhost, mTLS, SSO proxy — whatever your estate uses). The bundle itself is inert static HTML; there is nothing session-based to protect inside it.
  2. Ops dashboard included/ops/ ships in the same bundle, so the operator view is available internally with no extra steps.

Notes and sharp edges

  • The routine cannot verify your internal site. With site_url set to an internal URL, the Anthropic-managed routine container generally cannot reach it; leave site_url: "" and treat "on main" as the routine's definition of published — the timer owns the last mile. If you point site_url at a URL the container can reach, Phase 7 polls it as usual.
  • Custom domain artefacts: CNAME and .nojekyll are Pages-specific and harmless to keep; delete CNAME if you disable Pages to avoid confusion.
  • Feed scripts (intel drops, source-health cron) need push access to the private repo — deploy keys or a machine PAT scoped to the repo.
  • Back to public: re-enable deploy-site.yml and make the repo public — but first audit intel/ and every published entry, because the pipeline applies no TLP filter and git history is forever once public. Anything you would not want world-readable must be scrubbed from history before the repo goes public.