# Connect a website to Tusk CMS You are an AI coding agent working inside a website's own repository, or driving the human's browser alongside it. This document is the complete, authoritative instruction for connecting that site to Tusk CMS so that publishing in Tusk updates the live site by itself. Follow it in order. Everything you need is here; do not guess, and do not ask the human anything this document answers. Canonical copies, identical byte for byte: https://tuskcms.com/llms-full.txt https://tuskcms.com/api/connect Worked example of a finished, marked-up page: https://tuskcms.com/examples/marked-site.html The one line a site owner pastes to start you off: "Connect this site to Tusk CMS. Read https://tuskcms.com/llms-full.txt first and follow it exactly." This guide has three parts. PART ZERO is the preferred path: the whole job as calls to the Tusk MCP server over a token limited to one site, with one Approve tap from the human. Use it whenever your tool can run MCP servers. PART ONE is the same job done by hand through the human's browser (the fallback, written for a git repository with a build step deployed on Cloudflare Pages). PART TWO is the reference both lean on: the full `data-tusk` attribute grammar, the field kinds, the publishing modes, how to render pulled content, the public feed contract, the endpoint table, and the rules about secrets. Read a reference section when a step points you to it. =============================================================================== PART ZERO — THE MCP PATH (preferred; use it whenever you can run MCP servers) =============================================================================== This is the whole job as tool calls. It does exactly what PART ONE does by hand, over a token that can reach ONE site and only the actions the human approved. Read PART TWO sections A-D before you mark anything (the attribute grammar), and section H before you touch a token. Everything else you need is here. ## Z0. What you need from the human (one message, then don't block) 1. The site's repository (you should already be inside it) and which folder is the deployable output (`dist/`, `out/`, `public/`, or the repo root for a plain HTML site). 2. The live domain (lowercase) and the current host (Vercel, Cloudflare Pages, Netlify, GitHub Pages, ...). Detect it if you can: `curl -sI https:///` and read `server` / `x-vercel-id` / `x-nf-request-id` / `cf-ray`. 3. Which pages should be editable. Recommend the homepage only for a first connection. 4. A short slug for the site in Tusk: lowercase letters, digits and hyphens, e.g. `north-studio`. If the site already exists in Tusk, its slug is in the dashboard URL (`/app?site=`). If not, you will create it under this slug in Z3. 5. Optional: the client's email address, if they want the invitation sent at the end. 6. Confirm they have a Tusk account (sign up at https://tuskcms.com/login?signup, fourteen days free, no card) and that they are logged in to their host's CLI on this machine (`vercel whoami`, `wrangler whoami`, `netlify status`), or are ready to make one click in the host's dashboard. Tell them: "This takes a few minutes for a small site, mostly your host's first build. I will stop once for a browser tap (approving Tusk access) and possibly once for your host (creating a deploy hook and setting one environment variable), and I will never touch your code beyond adding attributes." ## Z1. Install the Tusk MCP server It is on npm as `@tuskcms/mcp` and listed in the official MCP Registry as `com.tuskcms/mcp`. Nothing to install; add it to your MCP configuration: ```json { "mcpServers": { "tusk": { "command": "npx", "args": ["-y", "@tuskcms/mcp"] } } } ``` Claude Code: `claude mcp add tusk -- npx -y @tuskcms/mcp`. Codex, Cursor and others: their MCP settings file, same command and args. Restart or reload the tool so the `tusk` server's tools appear. ## Z2. Log in once (the human taps Approve) Run this in the repository, with the slug from Z0: ```sh npx @tuskcms/mcp login --site --scopes scan,fields,content,publish,deploy,invite ``` It prints a URL and a pairing code. Show the URL to the human and ask them to open it in a browser where they are signed in to Tusk and press **Approve**. Tusk also emails the site's studio an "Approve a device?" message with the same link, and shows the waiting request as a banner in the dashboard, so the human is told to approve rather than having to find the link in your output. That screen names the one site and the exact capabilities being granted. The command then saves the token on this machine (`%APPDATA%\tusk\credentials.json` on Windows, `~/.config/tusk/credentials.json` elsewhere). You never see the token, and you must never look for it, print it or copy it anywhere. What that token can do: act on `` and no other site, and only the listed actions. It cannot mint other tokens and cannot touch code or hosting. It expires by itself after 30 days (`--ttl `, at most 90); the human gets an email the moment it is approved and can revoke it in the dashboard under Studio settings > Connected tools & devices. Drop `invite` if the human does not want you to send the invitation. Verify: call the `whoami` tool (or `npx @tuskcms/mcp whoami`). It names the studio and the site the token is limited to. ## Z3. Register the site in Tusk Call `list_sites`. If a site with `` exists, use it. Otherwise call `create_site` with `name` (the human name, e.g. "North Studio"), `domain` (the live hostname), and `previewUrl` (an https address Tusk can fetch right now: the live site, or a preview deployment). Leave `deployHook` empty for now. With a site-bound token the site is created under `` regardless of the name. "Create" here means registering the existing website inside Tusk. It creates nothing on the web and changes nothing in the repository. A site created by the MCP starts empty: no demo pages, no placeholder fields — your scan defines everything (a site added in the dashboard with "Start empty" unticked carries a worked-example starter; the first real scan clears its untouched placeholder text and drops its unscanned demo pages, so either way the first publish ships the site's own words). ## Z4. Mark the HTML Add `data-tusk="page.field"` to the elements the owner should edit, exactly as PART TWO sections A-D describe. Attributes only: no change to layout, styles, class names, markup order, tag names or a single word of copy. The text on the page becomes the starting content in Tusk. Call `get_instruction` for the site first: it returns an instruction written for this site, including any pages and fields Tusk already holds, so the keys you write into the HTML are the keys Tusk expects. If the site is new it is a compact restatement of the grammar with the site's own values filled in. Checkpoint: `grep -c 'data-tusk="' /index.html` equals the number of elements you meant to mark, and the page renders unchanged. ## Z5. Wire the build Download the two shipped scripts into the repository and commit them: ```sh curl -O https://tuskcms.com/sdk/tusk-pull.mjs curl -O https://tuskcms.com/sdk/tusk-apply.mjs ``` Build command, run by the host on every deploy (substitute the output folder): ``` node tusk-pull.mjs && node tusk-apply.mjs dist ``` For a framework site (Next, Astro, Eleventy, Hugo ...) keep the existing build and run the pull step before it, rendering from `.tusk/published.json` in the templates (PART TWO, section F), or run the apply step after it against the real output folder. Add the command to `package.json` `scripts.build` (or the host's build command setting). If you marked photos, set `TUSK_ASSETS=/tusk`. Then call `rotate_build_token { slug }` from the repository root. It writes `TUSK_SITE`, `TUSK_URL` and `TUSK_TOKEN` into `.env` itself, adds `.env` to `.gitignore` if it is not ignored, and returns only a masked preview: you never see the token and must never try to (do not `cat .env`; pass `reveal:true` only if you genuinely must handle the value, and then never print it). The token is a SECRET: its only homes are that git-ignored `.env` and the host's environment (Z6). Check the result's `gitIgnored` is true before you commit anything. Write `.env.example` with the names only: ```sh TUSK_SITE= TUSK_TOKEN= TUSK_URL=https://tuskcms.com ``` Checkpoint: with `.env` loaded, `node tusk-pull.mjs` prints one summary line and writes `.tusk/published.json`; `node tusk-apply.mjs ` lists the fields it wrote (zero is correct before the first publish: the built-in text is the fallback). ## Z6. Wire the host: the build variables and the deploy hook Tusk never hosts the site and never holds hosting credentials. You wire the site's OWN host, then tell Tusk the hook URL with `set_deploy`. Do this with the host's CLI when the human is logged in to it; otherwise ask the human for the one click in the dashboard. Read the token value from `.env` in a way that never echoes it (`grep '^TUSK_TOKEN=' .env | cut -d= -f2-` piped straight in, or the CLI's interactive prompt). | Host | Environment variables (TUSK_SITE, TUSK_TOKEN, TUSK_URL; plus the build command and output folder if not already set) | Deploy hook | |---|---|---| | **Vercel** | `vercel env add TUSK_TOKEN production` (prompts for the value; repeat for the other two), or Project → Settings → Environment Variables | Dashboard only: Project → Settings → Git → Deploy Hooks → Create Hook, on the production branch (Git-connected projects only). The human copies the URL to you, or pastes it into Tusk (Manage → Going live). A project with no Git repository uses `vercel://@/` as the hook: Tusk redeploys the last production build. | | **Netlify** | `netlify env:set TUSK_TOKEN "$(...)"` (the value comes from a substitution, never typed), or Project configuration → Environment variables | `netlify api createSiteBuildHook --data '{"site_id":"","body":{"title":"tusk","branch":"main"}}'`, or dashboard: Project configuration → Developer settings → Continuous deployment → Build hooks → Add build hook. Build command and publish dir: Project configuration → Build & deploy → Build settings. | | **Cloudflare Pages** (Git-connected) | `wrangler pages secret put TUSK_TOKEN --project-name `; plain vars in Settings → Variables and secrets (older dashboards: Environment variables) | API: `POST /accounts//pages/projects//deploy_hooks` `{"name":"tusk","branch":"main"}` with a token holding Cloudflare Pages: Edit, or dashboard: Settings → Builds → Deploy hooks → Add deploy hook. Build command and output dir: Settings → Builds → Build configuration. Direct-upload projects have no hook: adapter mode (section E) or the feed. | | **GitLab Pages** | Settings → CI/CD → Variables (TUSK_TOKEN masked) | Settings → CI/CD → Pipeline trigger tokens → Add new token; the hook URL is `https://gitlab.com/api/v4/projects//trigger/pipeline?token=&ref=main` (GitLab reads the token from the URL, so Tusk's plain POST starts the pipeline). The `pages` job runs pull, build, `node tusk-apply.mjs public`, and allows `$CI_PIPELINE_SOURCE == "trigger"`. | | **AWS Amplify Hosting** | App → Hosting → Environment variables | App → Hosting → Build settings → Incoming webhooks → Create webhook (name, branch); a POST to the URL starts a build. amplify.yml: `node tusk-pull.mjs` in preBuild, `node tusk-apply.mjs dist` after the build, baseDirectory dist. | | **Render** (static site) | The service's Environment tab | Settings → Deploy Hook (GET or POST, no headers). Build Command and Publish Directory under Settings. | | **GitHub Pages / Firebase Hosting / Azure Static Web Apps / FTP, cPanel, buckets, any server** | GitHub Actions: Settings → Secrets and variables → Actions (TUSK_TOKEN a secret; TUSK_SITE, TUSK_URL variables) | No hook. Use PART ONE, Step 8-alt: a workflow does pull, apply and deploy; Tusk's signed webhook reaches it through the relay Worker set to GitHub's `repository_dispatch` (DEPLOY_HOOK_URL, DEPLOY_HOOK_AUTH, DEPLOY_HOOK_BODY). Firebase: `firebase init hosting:github` writes the deploy workflow. Azure: the workflow it created; extend its `on:` and `if:`. | | **Railway / Fly** | Dashboard env vars | A "Deploy hook" style trigger under the service's settings where the host offers one; otherwise Step 8-alt. | Deploy hooks are per branch: create it on the branch the production site is built from. The hook URL is a secret: pass it straight into `set_deploy`, never print it, never commit it. Then: ``` set_deploy { slug, deployMode: "hook", deployHook: "", previewUrl: "" } ``` If the host genuinely cannot offer a hook and cannot run GitHub Actions, use `deployMode: "feed"`. That also turns on the token-less public feed at `/api/public//snapshot` with CORS open, so the finished page can fetch it straight from the browser (one script tag; PART TWO, section G) or the build can pull it. Two things to know: the feed serves nothing until the first publish, so publish once in Z7 before you test it; and a visitor sees a publish within about a minute (pin `?v=` for instant). Say plainly to the human how their site picks up a publish. Push the marked HTML and the scripts so the host builds once with the new variables. Wait for that build to succeed before Z7. ## Z7. Scan, publish, prove it went live 1. `scan_site { slug, urls: ["https:///", ...], auto: false }` for every page you marked, using the addresses where the marked HTML is now deployed. Checkpoint: the field count per page equals your marks (`get_schema` or `get_setup` to review). If the count is far above your marks, `auto` was on. 2. `publish { slug, note: "first publish" }`. This stores the snapshot and calls the deploy hook. 3. `deploy_status { slug }` until it reports the host's build finished, then `curl -s https:///` and confirm the page still shows the original text (nothing has been edited yet, so nothing changes; the point is that the build succeeded with the scripts in it). Tusk confirms a publish is live by fetching `/tusk/build.json`, which `tusk-pull.mjs` writes on every build; if `deploy_status` never confirms, that file is not being deployed, so check the output folder and `TUSK_ASSETS`. 4. `get_setup { slug }`: every step should read done. Only `deploy_status` (or your own fetch of the live page) proves a publish is live. "Hook accepted" is not proof. ## Z8. Hand over - Write the repo note in PART TWO, section J into `AGENTS.md` (and `CLAUDE.md` if the repo uses one), with the slug and without the token. - If the human gave you a client email and the token has `invite`: `invite_client { slug, email, name }`. Tusk emails them a link; they continue with Google or Microsoft and land in the editor on this site only. Otherwise tell the human: Tusk → the site → Manage → Project → People → Give a client access. - Say, in these words: "Publish in Tusk now goes live by itself as soon as finishes its build. Code changes deploy as before, by pushing. The build token is in .env and in 's environment; if it is ever rotated in Tusk, update it in too." - Summarise: pages and fields marked (per page), what you left unmarked and why, the build command, the host and hook, and each checkpoint with its result. ## Z9. Done criteria (MCP path) 1. `get_setup` shows every step done and `deploy_status` has confirmed live. 2. `git ls-files` contains no `.env` or token file; `.env.example` has names only. 3. The live domain serves the marked HTML and `/tusk/build.json`. 4. The human knows how a publish reaches the site and where the token lives. 5. The token you paired is limited to this one site (check `whoami`) and expires by itself. Do not pair a studio-wide token for a single-site job. 6. You never printed, committed or quoted the build token; `.env` is git-ignored (the `rotate_build_token` result said so). ## If a tool call fails | Result | Meaning / fix | |---|---| | 401 from any tool | Not logged in, or the saved token was revoked. Run Z2 again. | | 403 "not allowed to " | The token was paired without that scope. Re-pair with it (Z2). | | 404 "No such site, or not yours" | Wrong slug, or the token is bound to a different site. `whoami` shows which. | | 409 on create_site | The slug already exists in this studio: use `list_sites` and continue with it. | | 402 | The studio's plan is out of site allowance; the human chooses a plan in Tusk. | | scan finds 0 fields | The marked HTML is not deployed at that address yet, or the page needs JavaScript to render. Deploy first; Tusk reads served HTML. | | scan answers 403 | Cloudflare Bot Fight Mode (or a WAF) is blocking the scanner on the real domain. Scan the `*.pages.dev` / preview address instead, or allow the user agent `TuskCMS-scanner` in a WAF rule. | | public feed answers 404 | The site's public feed is off (set_deploy with deployMode "feed" turns it on; or Manage → Going live → Advanced), or nothing has been published yet. Publish once. | | deploy_status stays "requested" | The hook is on the wrong branch, or the build failed on the host; read the host's build log. | | Dashboard shows "Build token out of date" | The host is building with an old TUSK_TOKEN; update it there and publish again. | If your tool cannot run MCP servers at all, do PART ONE instead: it is the same work through the human's browser. =============================================================================== PART ONE — THE RUNBOOK BY HAND (fallback when you cannot run MCP servers) =============================================================================== ## What Tusk is Tusk CMS gives the owner of a website a login where they can change the words, photos and downloads on it, without touching the code. The content lives in Tusk; the site stays in this repository, on its own hosting, and nothing of Tusk sits between a visitor and the site. You do two things: you add `data-tusk` attributes to the elements the owner should be able to edit, and you add one build step that writes the published content into the site when it deploys. ## What you are building — the mechanism For the primary path (a git repository deployed on a host that rebuilds from it): ``` Editor clicks Publish in Tusk -> Tusk stores the published content and calls the host's deploy hook -> the host (Cloudflare Pages) rebuilds the site from its Git repository; the build runs the two Tusk scripts, which fetch the published content and write it into the marked HTML elements -> the whole site — every page and every asset — is redeployed. Live in about two minutes. ``` Publishing in Tusk never changes the website by itself. The deploy hook is what makes a publish go live. Do not let the human believe otherwise. Tusk also never hosts the site or holds its hosting credentials: you wire the site's OWN host (the deploy hook), and Tusk only stores the content and remembers the hook URL. If you can run MCP servers, do PART ZERO instead of this part: it is the same mechanism as tool calls. The steps below are the fallback, done by hand. There are other ways a publish can reach a site (feed, adapter, signed webhook); they are in PART TWO, section E. The runbook uses the deploy-hook path because it is the one that redeploys the *whole* site — every page and asset — which is what a real multi-page static site needs. ## What to tell the human up front - Budget 30-40 minutes, most of it waiting on builds. - You will stop **four times** for a click only they can make, in this order: **GitHub sign-in -> Cloudflare "Authorise GitHub" -> paste the Tusk build token into Cloudflare -> remove the domain from the old project.** - After it is done, an editor clicks Publish in Tusk and the live site updates itself in about two minutes, with no developer step per edit. ## Step 0. Collect inputs from the human in one message Ask for all of these at once, then do not block again until an intervention: 1. Path to the deployable site folder (the one containing `index.html`). 2. The live domain (lowercase) and whether `www` is used. 3. Current hosting, and — if Cloudflare Pages — whether the project is direct-upload or Git-connected, and its name. 4. Which pages should be editable. Recommend the homepage only for a first connection. 5. Confirmation they have accounts for Tusk, Cloudflare (with the domain's DNS on Cloudflare) and GitHub. Then tell them the four interventions listed above. ## Hard rules - Never type a password, API token or build token into a web form. Hand it to the human. Never click "Authorise" on an OAuth screen and never create an account. Those are the human's clicks. - Never commit or deploy a token. The env file goes in `.gitignore`; verify with `git ls-files`. See PART TWO, section H. - Never push the human's whole working folder. The repository is the deployable site folder plus the two build scripts, `package.json` and `.gitignore` only. - ADD ATTRIBUTES ONLY when you mark up. Every page must render byte-identically until the build step is wired: never change layout, CSS, class names, ids, markup order, nesting, the tag an element uses, or a single word of the existing copy. The text on the page becomes the starting content in Tusk. - Mark plain-text elements. You *may* mark an element that contains only decorative inline children — a styled ``, a `
`, a leading inline icon — as ordinary `text`: Tusk edits only its own words and leaves the children in place (PART TWO, section A.6). Reach for `data-tusk-kind="html"` only when the client should edit the inline markup itself. Do not mark navigation, form controls, submit buttons, cookie banners, scripts, or legal text the owner should not change. - Do not use Tusk's **Adapter** publishing mode for a multi-page static site. Adapter mode uploads only the page templates Tusk captured at scan time and the images those pages reference; anything it did not capture — a page you did not scan, your CSS/JS, fonts, non-Tusk images — is not in that deployment. The deploy-hook path in this runbook redeploys the whole repository instead. (When adapter mode *is* the right choice, and why, is in PART TWO, section E.) - Never test the cloud path while any stop-gap script, poller or scheduled task is still deploying. Disable it first. - If your environment blocks destructive actions, do not remove a live domain yourself; that is intervention 4. Do not touch the live domain until the new project serves every page and asset. ## Step 0b. Identify the host and pick the route Everything in this runbook is the same regardless of host **except** three things: how the site is built from Git (Step 7), where the deploy hook comes from (Step 8), and how the domain moves (Step 10). Work out which route applies before you start, and tell the human which one. Ask, or detect from the code/DNS/HTTP headers (`curl -sI https:///` and read `server` / `x-served-by` / `x-vercel-id` / `x-nf-request-id`): | Host / situation | Route | What changes | |---|---|---| | **Cloudflare Pages, Git-connected already** | A | Skip repo creation in Step 6-7; just add build command, output dir and env vars to the existing project; hook from its Settings. No domain move. | | **Cloudflare Pages, direct-upload** (`wrangler pages deploy`) | A | Exactly as written: new Git project, move domain. | | **Netlify** (Git-connected or drag-drop) | A | Site → Build & deploy: build command below, publish dir `dist`, env vars; **Build hooks → Add build hook** gives the URL. Drag-drop sites: create a new Git-connected site and move the domain in Domain management. | | **Vercel** | A | Project → Settings: build command below, output `dist`, env vars; **Git → Deploy Hooks → Create Hook** gives the URL. Domain moves under Settings → Domains. | | **GitHub Pages** | A′ | No deploy hook. Use the GitHub Actions workflow in Step 8-alt; it runs the scripts and publishes to Pages. Trigger it from Tusk's signed publish webhook via `repository_dispatch`. | | **Render / Railway / Fly static** | A | Same shape: build command + publish dir + env vars in the dashboard; each has a "Deploy hook" URL under settings. | | **S3 + CloudFront, Azure Static Web Apps, Firebase Hosting, any bucket** | B | No build service to hook. Use GitHub Actions (Step 8-alt) to run the scripts and deploy with the host's CLI (`aws s3 sync` + invalidation, `swa deploy`, `firebase deploy`). Needs a host credential stored as a GitHub secret (human pastes). | | **cPanel / plain FTP / SFTP** | B | GitHub Actions + an FTP deploy action. Host credential as a GitHub secret. | | **Webflow, Wix, Squarespace, Shopify themes** | — | Not a static host you control; Tusk's `data-tusk` model does not apply. Stop and tell the human. | | **Site is a framework** (Next, Astro, Eleventy, Hugo…) | A | Keep the existing build; run the Tusk pull step **before** it and render from the JSON in the templates (PART TWO, section F), or run the apply step **after** it against the real output folder. e.g. `node tusk-pull.mjs && npm run build`, or `npm run build && node tusk-apply.mjs `. | **Cloudflare Pages is the default and the recommended target.** If the human is not yet on a host with a deploy hook and is open to moving, recommend Cloudflare Pages: free tier, DNS in the same place (the domain cutover becomes seconds), and every step below is verified there. Do not propose a migration unprompted if the site already works on another Route A host. **Route A** = the host builds from Git and offers a deploy hook. Follow the runbook, substituting the host's names for Cloudflare's. **Route B** = the host is a dumb file target; the build and deploy run in GitHub Actions instead, and Tusk triggers it through its signed publish webhook. Either way the human makes only the same four kinds of clicks. If you cannot tell which route applies after inspection, ask one question: *"When you change the site today, how does it get online?"* Their answer (git push / a dashboard / a command / FTP) maps directly to the table. ## Step 1. Create the Tusk site (agent, in the human's browser) Tusk → **Add a site** → name, live domain (lowercase), preview URL (the current `*.pages.dev`), deploy hook blank → **Create site**. Read the site key (the slug) from the URL (`?site=`); record it as `TUSK_SITE`. Check the site list for an accidental duplicate (`-2`); if present, rename it "ZZ DO NOT USE" (Tusk has no delete) and use the real key. ## Step 2. Build token (agent; human enters the value if it cannot be captured) Tusk → site → **Manage → Going live → Advanced → Build token → New build token** → confirm. The token is shown once. Capture it from the response immediately. It is a secret; store it only in a local, git-ignored `.env` next to the site folder: ``` TUSK_SITE= TUSK_URL=https://tuskcms.com TUSK_TOKEN= ``` If you cannot capture it, ask the human to copy it and paste it into `.env` themselves. It never enters the repository. See PART TWO, section H for the full secrets rules before you write it. ## Step 3. Mark the HTML (agent; the only real content work) Add `data-tusk="page.field"` to each editable element. Use `data-tusk-page` on `` to name the page; use the page key `globals` for text repeated on every page (a nav button, footer lines). The full grammar — every attribute, the field kinds, lists, the reserved ids, and the limits — is PART TWO, sections A-D. Read it before you mark anything non-trivial. In short: - Mark intro/lead paragraphs, body copy, card titles and text, bios, button labels, footer lines. Read every element before marking it. - Do not change any wording, class, tag, or the markup order; attributes only. - Mark a page completely or not at all: as soon as one mark exists on a page, Tusk stops guessing at the rest of that page (PART TWO, section D). Write `.tusk/schema.json` listing the pages and fields you marked, in the repo root (not inside the deployable folder). Checkpoint: count the marks (`grep -c 'data-tusk="' dist/index.html`) and confirm the page still renders unchanged. ## Step 4. Wire the build (agent) The build fetches the published content and writes it into the marked HTML. Two shipped, zero-dependency Node scripts (Node 18+) do this — download them into the site folder and commit them. There is no single "tusk-sync" script; these two together are it. `tusk-pull.mjs` fetches the published snapshot (and any photos); `tusk-apply.mjs` writes the published values into the built HTML you marked. ```sh curl -O https://tuskcms.com/sdk/tusk-pull.mjs curl -O https://tuskcms.com/sdk/tusk-apply.mjs ``` The build command is the two in sequence, against the deployable folder: ``` node tusk-pull.mjs && node tusk-apply.mjs dist ``` Environment variables the scripts read (set them on the host in Step 7, and in your local `.env` for the checkpoint below): `TUSK_SITE`, `TUSK_TOKEN`, `TUSK_URL=https://tuskcms.com`. If you marked any photos, also set `TUSK_ASSETS=dist/tusk` (default is `public/tusk`) so the pulled images land inside the deployable folder, and leave `TUSK_PUBLIC=/tusk` as is. For a homepage-text-only first connection no photos are involved and the defaults are fine. Full variable list: PART TWO, section F. Checkpoint: with the variables set, run `node tusk-pull.mjs`. It prints one summary line (`tusk: · N pages · …`) and no HTTP error, and writes `.tusk/published.json`. Then `node tusk-apply.mjs dist` prints the fields it wrote per page. If the site has not been published in Tusk yet the pull may report the live view with no stored snapshot; that is fine — the apply step leaves the built-in text in place, which is the correct fallback. ## Step 5. Deploy the marked HTML and scan (agent) Deploy the marked site to the current host by whatever method it already uses. Then Tusk → site → **Manage → Scan for editable fields**: paste the page URL(s), untick "also find fields without data-tusk marks", click **Scan for editable fields**, then **Save**. Checkpoint: the reported field count equals your mark count, and the editor shows the fields beside a live preview. ## Step 6. Repository (agent; INTERVENTION 1) Create a new folder outside any cloud-synced directory containing only: the deployable site folder (`dist/`), `tusk-pull.mjs`, `tusk-apply.mjs`, `package.json`, `.gitignore`. ```json { "name": "site", "private": true, "type": "module", "scripts": { "build": "node tusk-pull.mjs && node tusk-apply.mjs dist" } } ``` `.gitignore`: `.env`, `.tusk/`, `node_modules/` ```sh git init -b main && git add -A && git commit -m "Site with Tusk content sync" git ls-files | grep -i env # must print nothing ``` Open github.com/new in the browser → name, **Private**, no README → Create. **INTERVENTION 1:** if GitHub shows a sign-in page, ask the human to sign in. ```sh git remote add origin https://github.com//.git git push -u origin main ``` Run the push in the background. On Windows a "CredentialHelperSelector" window may appear: tell the human to keep **manager**, click Select, and approve the GitHub window that follows. Checkpoint: `git ls-remote --heads origin` lists `main`. ## Step 7. Cloudflare Pages Git-connected project (agent; INTERVENTIONS 2 and 3) A direct-upload Pages project cannot be converted; create a new one. Cloudflare → **Workers & Pages → Create → Pages → Import an existing Git repository**. **INTERVENTION 2:** ask the human to click **Connect GitHub → Authorise** and grant the repository. Then: select the repository → **Begin setup** → production branch `main`, framework preset **None**, build command `node tusk-pull.mjs && node tusk-apply.mjs dist`, build output directory `dist`. Environment variables (add to **Production and Preview**): `TUSK_SITE`, `TUSK_URL` = `https://tuskcms.com`, a row named `TUSK_TOKEN`, and — if you marked photos — `TUSK_ASSETS` = `dist/tusk`. **INTERVENTION 3:** ask the human to paste the token value into `TUSK_TOKEN` (give them the value from `.env`). Then click **Save and Deploy**. The first build may show "Initializing build environment" for 3-6 minutes; that is normal. Checkpoint (mandatory): with `curl`, confirm the new `*.pages.dev` returns 200 for the homepage, a deep page, a CSS file and an image, and that a marked field shows the text currently published in Tusk. ## Step 8. Deploy hook (agent) Cloudflare → new project → **Settings → Builds → Deploy hooks → Add deploy hook** → name `tusk-publish`, branch `main` → Save → copy the URL at creation (it is masked afterwards). If the settings page is heavy and screenshots time out, drive it through the accessibility tree or ask the human to copy the URL. Tusk → site → **Manage → Going live** → set "How publishing reaches the site" to **Deploy hook** (it is not the default label shown, so set it explicitly) → paste the URL → **Save**. The deploy-hook URL is a secret; the human pastes it into Tusk, and it is stored encrypted there. Never write it into the repo or print it. Checkpoint: the mode reads Deploy hook and the field holds the URL (masked). ## Step 8-alt. No deploy hook available (Route B, GitHub Pages, buckets, FTP) When the host cannot rebuild itself, GitHub Actions does the pull-apply-deploy in the cloud and Tusk triggers it. Still PC-independent. 1. Add `.github/workflows/tusk-deploy.yml`: ```yaml name: Tusk publish -> deploy on: repository_dispatch: types: [tusk-publish] workflow_dispatch: schedule: - cron: "*/10 * * * *" # safety net: also re-check every 10 min jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20 } - name: Pull published content and write it into the HTML env: TUSK_SITE: ${{ vars.TUSK_SITE }} TUSK_URL: https://tuskcms.com TUSK_TOKEN: ${{ secrets.TUSK_TOKEN }} run: node tusk-pull.mjs && node tusk-apply.mjs dist # --- pick ONE deploy step for the host --- # GitHub Pages: # - uses: actions/upload-pages-artifact@v3 # with: { path: dist } # - uses: actions/deploy-pages@v4 # S3 + CloudFront: # - run: aws s3 sync dist s3://$BUCKET --delete && aws cloudfront create-invalidation --distribution-id $DIST_ID --paths "/*" # env: { AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}, AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}, BUCKET: ${{ vars.BUCKET }}, DIST_ID: ${{ vars.DIST_ID }} } # FTP/SFTP: # - uses: SamKirkland/FTP-Deploy-Action@v4 # with: { server: ${{ secrets.FTP_HOST }}, username: ${{ secrets.FTP_USER }}, password: ${{ secrets.FTP_PASS }}, local-dir: dist/ } ``` 2. **INTERVENTION (replaces 3):** ask the human to add the repository secret `TUSK_TOKEN` (and any host credential) under GitHub → repo → Settings → Secrets and variables → Actions, plus the variable `TUSK_SITE`. You never enter these. 3. Trigger from Tusk. Set the site's **Publish webhook URL** (PART TWO, section E, path B) to a tiny receiver that turns Tusk's signed publish payload into a GitHub `repository_dispatch` (GitHub's API needs an auth header Tusk cannot send directly). Tusk ships a copy-paste Cloudflare Worker at `https://tuskcms.com/sdk/tusk-deploy-worker.js`; deploy it unchanged with four variables: `TUSK_WEBHOOK_SECRET` (the signing secret Tusk shows once), `DEPLOY_HOOK_URL=https://api.github.com/repos///dispatches`, `DEPLOY_HOOK_AUTH=Bearer ` (the human creates it; `wrangler secret put`), and `DEPLOY_HOOK_BODY={"event_type":"tusk-publish"}`. If the human would rather skip the receiver, the `schedule` line alone gives a <=10-minute delay with zero extra parts. Say which trade-off you chose. 4. Prove it exactly as in Step 9, watching the host's URL instead of `pages.dev`. ## Step 9. Prove the cloud path (agent) Disable any stop-gap deployer first. In the Tusk editor change one real field → **Publish** (choose "only this page" if offered). Note: a test publish with no new drafts does not call the hook; use a real edit. Poll the `*.pages.dev` URL every 15 seconds for up to 6 minutes until the new text appears. That is the proof. If it never appears: confirm the mode is Deploy hook and the URL is saved, then that the hook exists in Cloudflare. ## Step 10. Domain cutover (INTERVENTION 4, then agent) **INTERVENTION 4:** ask the human: old project → **Custom domains** → for the apex and `www`: ⋯ → **Remove domain** → confirm. (Adding to the new project first fails with "already associated with an existing project".) Then: new project → **Custom domains → Set up a custom domain** → apex → Continue → **Activate domain**; repeat for `www`. Ignore "may take up to 48 hours": with DNS on Cloudflare it activates in seconds. Poll `https:///` until the edited word appears; confirm `www` and several deep paths return 200. ## Step 11. Finish (agent) - Remove any stop-gap scheduled task or manual deploy script. - Tell the human, in these words: **"Publish in Tusk now goes live by itself in about two minutes. Code changes deploy by pushing to the repository; the old deploy command no longer affects the site."** - The old project stays without domains as a fallback; suggest deleting it after a week. - Offer to invite an editor (Tusk → Manage → Give a client access) and note there is no review step before changes go live. - Write the repo note in PART TWO, section J into `AGENTS.md` so the next session starts informed. ## Done criteria 1. `curl https:///` shows the last text published in Tusk. 2. A Tusk publish updates the live domain within ~3 minutes with no command run. 3. `git ls-files` contains no `.env` or token file. 4. No local watcher, cron or manual deploy script remains. 5. The human knows publish is not live without the hook, and how code changes deploy now. ## If something fails | Symptom | Fix | |---|---| | Published, preview unchanged after 6 min | Mode still Feed or hook URL not saved; fix and publish a real edit | | Feed returns 401 | Token wrong or rotated; regenerate in Tusk, update `.env` and the Cloudflare variable | | Apply writes 0 changes while the feed differs | Field id mismatch; check the marks against the field keys (PART TWO, section A.3) | | "Domain already associated" | Still attached to the old project; INTERVENTION 4 | | Cloudflare settings page frozen for screenshots | Use accessibility refs or page JavaScript, or hand the click to the human | | Field count far above your marks | Auto-detect was on; re-scan with it off | | Edits vanish | Editing the duplicate `-2` site; use the real key | | First build stuck initialising | Cold sandbox; wait up to 6 minutes | =============================================================================== PART TWO — REFERENCE =============================================================================== ## A. The attribute grammar Nine attributes, and nothing else. This is the complete set the scanner reads. | Attribute | Goes on | Meaning | | --- | --- | --- | | `data-tusk="page.field"` | the element being edited | one editable field | | `data-tusk-page="About"` | `` | names this page and sets its key | | `data-tusk-label="Hero photo"` | any marked element or sub-field | the name the owner sees in the editor | | `data-tusk-kind="textarea"` | any marked element or sub-field | overrides the kind Tusk infers | | `data-tusk-aspect="16:9"` | a photo field or photo sub-field | the crop the editor suggests | | `data-tusk-list="page.field"` | the wrapper of a repeated group | one list field | | `data-tusk-item` | each repeated child of that wrapper | one entry | | `data-tusk-field="name"` | a part inside an item | one sub-field of the entry | | `data-tusk-max="8"` | the list wrapper | the most entries the owner may add | ### A.1 One field ```html

A small studio. A fresh perspective.

``` The attribute goes on the element that *holds* the content, not on a parent and not on a child. ### A.2 Page-level naming ```html ``` `data-tusk-page` on `` sets both the page's key (slugged: `About Us` becomes `about-us`) and the title the owner sees. Put it on every page you mark. With it set, a bare field name is allowed: ```html

Who we are

``` Without `data-tusk-page`, the key comes from the URL: `/` and `/index.html` are `home`, `/about/` is `about`, `/services/fitouts/` is `services-fitouts`. Prefer the explicit attribute; it survives a URL change. ### A.3 Key naming A key is `page.field`, or the bare `field` shorthand when the page key is already settled by `data-tusk-page` or the URL. - The page part is slugged: lower case, anything that is not a letter or a digit becomes `-`. - The field part keeps only letters, digits and `_`. Anything else becomes `_`. Use `snake_case`: `home.services_heading`, not `home.servicesHeading`. - Reserved field ids, which the scanner silently drops — never use them: `id`, `key`, `kind`, `title`, `url`, `order`, `state`, `portal`, `updatedAt`, `tenant`. - One key, one element. If the same key is marked twice on a page, the first wins and the rest are dropped silently. Check for duplicates yourself. ### A.4 Site-wide values: the `globals` page key Text that appears on every page — a footer line, a phone number, an address — uses the page key `globals`. Tusk shows it as "Site-wide text", one entry all pages share. ```html

North Studio Pty Ltd · Brisbane · Since 2019

``` Mark a `globals.*` key on ONE page only, even though the element appears on every page. The build step then writes that one value into every page that carries the mark. So: mark once for the scan, and the apply/render step fills it everywhere. ### A.5 The field kinds Five kinds. Tusk infers the kind from the tag and the content; set `data-tusk-kind` only when the inference is wrong. | Kind | Use it for | Inferred when | | --- | --- | --- | | `text` | one line: a heading, a label, a button's words, a name | the default | | `textarea` | paragraphs: an intro, a bio, a description | the element contains `

` tags, or its text is longer than 140 characters, or it has a blank line in it | | `photos` | an image slot; the first photo is the one on the page | the element is ``, `` or `

` | | `file` | a download: a PDF, a Word or Excel document, a zip | the element is `` and its `href` ends in `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx` or `.zip` | | `list` | a repeated group: services, team, testimonials, logos | declared with `data-tusk-list`, never inferred | Inside a list item, a sub-field's kinds are `text`, `textarea` and `photo` (singular — one photo per entry). Choosing between them: - A short heading that must stay on one line: `text`. Do not let a 150-character heading become a `textarea` by accident — set `data-tusk-kind="text"`. - A block the owner should be able to split into several paragraphs: `textarea`. Values come back with a blank line between paragraphs. - A logo, portrait or hero image: `photos`. Add `data-tusk-aspect` (`16:9`, `1:1`, `4:5`) so the editor's cropper matches the design. - A link to a document: `file`. A link to a file on *this* site becomes a placeholder in Tusk (Tusk does not copy your existing file across) until the owner uploads one. A link to a file on another domain is kept as-is. - Anything the owner should be able to add to and remove from: `list`. `data-tusk-kind="html"` marks an element whose inline markup the client may edit, not just its text. Allowed inline tags: `a`, `span`, `b`, `strong`, `i`, `em`, `u`, `s`, `small`, `sup`, `sub`, `mark`, `code`, `abbr`, `time`, `q`, `br`, `wbr`, `del`, `ins`; safe attributes only; `script`/`style`/`svg`/handlers/the `style` attribute and non-`http(s)`/`mailto`/`tel` links are stripped. For a heading that merely contains a styled span, a `br`, or a leading inline `svg` icon, you do NOT need this: mark it as normal `text` and Tusk edits only its own words, leaving the decorative children in place. ### A.6 One example per kind ```html

Architecture and interiors, Brisbane

A small studio. A fresh perspective.

Thoughtful spaces, made for everyday life. We work with families and small businesses who want a building that fits how they actually live.

New homes and extensions, from first sketch to handover.

Interiors that make the most of light, storage and the way you move through a room.

A bright living room

Download our capability brochure.

Talk to us

``` ## B. Lists: the nesting rule Three attributes, three levels, in this exact arrangement: ```html

Mia Chen

Director, architecture

Mia set up the studio in 2019 after a decade on housing and school projects.

Tom Okafor

Interiors lead

Tom looks after interiors and the way a room actually gets used.

``` Rules the scanner enforces: - `data-tusk-item` elements count only when the nearest enclosing `data-tusk-list` is this wrapper. Do not nest one list inside another. - `data-tusk-field` parts count only when the nearest enclosing `data-tusk-item` is that item. - THE SHAPE OF EVERY ENTRY COMES FROM THE FIRST ITEM. Mark the first item completely: every sub-field the entry can have, with its label, kind and aspect. Later items supply content only. A sub-field on the second item but missing from the first does not exist. - Mark every existing item, not just the first. Each marked item becomes one entry of starting content. - A sub-field id is sanitised like a field id, and `id` is not allowed as one. - A plain `data-tusk` mark inside a list wrapper is ignored. Everything inside a list is the list's. - `data-tusk-max` must be a whole number greater than zero. ## C. Limits The scanner refuses or truncates past these. Stay well under them. - 400 marks per page. - 200 items per list. - 12 sub-fields per list, taken from the first item. - 2 MB per page fetched, 15 seconds to fetch it. ## D. The one thing that surprises people If a page carries no marks at all, Tusk guesses at what is editable (headings, paragraphs, images, PDF links, repeated cards) and binds those by CSS selector. As soon as a page carries ONE mark, that guessing is off for that page by default. So mark a page completely or not at all: half-marking a page hides the rest of it from the owner. ## E. How a publish reaches the live site — the four modes A publish only changes what the *next* build pulls, so something has to run that build. In Tusk under Manage → Going live the human picks one mode (or the agent calls `set_deploy`): - **Deploy hook** (git-connected hosts; the runbook's path, and the recommended one for a multi-page static site). The human sets an https address their host gives them — Vercel, Netlify and git-connected Cloudflare Pages all have one. Tusk calls it after every publish and the host rebuilds from Git and pulls, so the *whole* site — every page and asset — redeploys. A Vercel project with no git repository can instead use `vercel://@/`, which redeploys its last production build. This is the default mode for a new site. - **Feed** (no hook; direct-upload and other static hosts). Nothing is pinged. The build pulls the feed the next time it runs, or — if the public feed is on (section G) — the live site fetches the feed itself and updates with no rebuild. This is the mode for a host that cannot be rebuilt on publish, chiefly Cloudflare Pages **direct upload**, which has no deploy hook. - **Adapter** (no hook, no build, no developer step). Tusk itself applies the published values to each page's captured template, resolves the images those pages reference, and uploads the finished files straight to the host through its direct-upload API. The built-in adapters are Cloudflare Pages and Vercel direct upload: the human pastes an API token scoped to the host's Pages/Deploy permission (least privilege), an account/team id and a project name. A failed deploy never blanks the live site — Tusk builds the whole file set first and the host keeps the previous deployment serving until a new one uploads whole, and Tusk retries. WHEN TO USE IT, AND WHEN NOT TO: adapter mode deploys only the pages Tusk captured at scan time plus the images those pages reference. Pages you did not scan, and static assets that are not Tusk-managed media — your CSS, JS, fonts, non-Tusk images — are not in that deployment. So adapter mode suits a small site Tusk has fully captured, where the owner wants zero developer involvement and there is no separate asset or build pipeline. For a multi-page static site with its own CSS/JS/images, use the deploy-hook path (the runbook) so the entire repository redeploys. Re-scan the site to refresh the captured templates after you change the markup. - **Signed webhook** (any host, alongside any mode). Tusk POSTs a signed JSON payload to a URL the human controls; a small receiver verifies the signature, pulls the public feed, and deploys wherever it likes. See path B below. AN ADAPTER API TOKEN, A WEBHOOK SIGNING SECRET AND A DEPLOY-HOOK URL ARE ALL SECRETS: set by the human in the dashboard, stored encrypted, never asked for, printed, or written into the repo. ### E — path B: a signed webhook to any host For any host, the human sets a **webhook URL**. On every publish Tusk POSTs a signed payload to it; a receiver you write verifies it, pulls the public feed, and deploys however it likes. Tusk mints a signing secret when the URL is set (shown once). Request headers: `X-Tusk-Event: publish`, `X-Tusk-Timestamp: `, `X-Tusk-Signature: sha256=`. Body: ``` { "event": "publish", "site": "North Studio", "slug": "north-studio", "publishedAt": "2026-…Z", "feedVersion": 1757600000000, "feedUrl": "https://tuskcms.com/api/public//snapshot" } ``` VERIFY BEFORE ACTING. The signature is `"sha256=" + HMAC_SHA256(secret, + "." + rawBody)`, over the raw body bytes, compared in constant time; reject a timestamp outside a few minutes' tolerance (this is what stops replay). Then fetch `feedUrl` (add `?v=` for an instant, fresh read) and deploy. A copy-paste Cloudflare Worker that does all of this is at `https://tuskcms.com/sdk/tusk-deploy-worker.js`. The receiver reads the token-less public feed, so the only secret it holds is the signing secret. ## F. How to render pulled content (template sites) The runbook writes published content into built HTML with `tusk-apply.mjs`. If the site renders through a framework instead, put the marks in the source templates and read the pulled JSON in them — never fetch Tusk at render time or runtime; the finished site must not talk to Tusk at all. `tusk-pull.mjs` writes `.tusk/published.json` and downloads photos. Variables it reads: | Variable | Required | Meaning | | --- | --- | --- | | `TUSK_SITE` | yes | the site's slug | | `TUSK_TOKEN` | yes | the build token — SECRET | | `TUSK_URL` | no | where Tusk runs; default `https://tuskcms.com` | | `TUSK_OUT` | no | where the snapshot is written; default `.tusk` | | `TUSK_ASSETS` | no | where photos are written; default `public/tusk` | | `TUSK_PUBLIC` | no | the URL path that serves `TUSK_ASSETS`; default `/tusk` | | `TUSK_FORCE` | no | `1` re-downloads every photo instead of skipping unchanged ones | `TUSK_ASSETS` and `TUSK_PUBLIC` must agree: if the site serves its static folder at `/`, and photos should live at `/tusk`, set `TUSK_ASSETS` to the folder that maps there. What lands after a run: `.tusk/published.json` (the whole snapshot, regenerated every build, never contains a token), the photos under `TUSK_ASSETS`, and `/build.json` (`{ site, publishedAt, snapshotAt, builtAt }`, which Tusk reads to confirm the live site is serving the current publish — leave it and deploy it). Shape of `.tusk/published.json` (and of the raw feed and the public feed): ``` { version: 1, site: { name, slug, domain, org }, publishedAt: '2026-…' | null, snapshotAt: '2026-…', pages: { : { kind, title, url, order, content: { : value } } }, media: { : { id, url, alt, kind, filename, mime, thumb, card, hero } }, schema: { pages: { : { fields: [ … ] } } }, // may be absent pulled: { at, from, assets, publicPath } } ``` Values, by kind: | Kind | The value in `content` | | --- | --- | | `text` | a string | | `textarea` | a string, paragraphs separated by a blank line | | `photos` | an array of `{ photo: , alt }`, or of bare media ids | | `file` | a media id, or `{ url, name }` for a file kept elsewhere, or `{ name, meta }` for a placeholder not yet filled, or `null` | | `list` | an array of `{ id, …subFieldId: value }`; a `photo` sub-field holds one media id or `null` | Write helpers once and use them everywhere; every read has a fallback equal to the text already on the page, so a site with no publish yet builds unchanged: ```js import { readFile } from 'node:fs/promises' const site = JSON.parse(await readFile('.tusk/published.json', 'utf8')) const get = (ref, fallback = '') => { const [page, id] = ref.includes('.') ? ref.split('.') : ['home', ref] const v = site.pages?.[page]?.content?.[id] return v === undefined || v === '' ? fallback : v } const paras = (ref) => String(get(ref, '')).split(/\n\s*\n/).map((s) => s.trim()).filter(Boolean) const asMedia = (e) => { if (e == null) return null const id = typeof e === 'object' ? (e.photo ?? e.id) : e const m = site.media?.[String(id)] return m ? { ...m, alt: (typeof e === 'object' && e.alt) || m.alt || '' } : null } const photo = (ref) => asMedia((get(ref, []) || [])[0]) ``` Read the file once, at module scope. A media URL is already rewritten to `/tusk/` by the pull step — put it straight into `src`, do not re-host it. `thumb`, `card` and `hero` are generated sizes on the same media record. ## G. The public, token-less feed (opt-in) CORS is open on this feed (`Access-Control-Allow-Origin: *`), so the finished site can fetch it directly from the browser — no proxy, no Worker, no token. It serves nothing until the site's first publish. Some hosts cannot be rebuilt on publish at all — Cloudflare Pages direct upload is the common one. For these, the human can turn on **Public content feed** under Manage → Going live → Advanced. Off by default, per site. When on, the published snapshot is readable with NO token at: ``` GET https://tuskcms.com/api/public//snapshot ``` and each image at `GET /api/public//asset/?size=thumb|card|hero`. The body is the same shape as section F, plus two fields: ``` { …everything in section F…, feed: 'public', feedVersion: 1757600000000 } // publish time in epoch ms, 0 if never published ``` The media URLs in this response already point at the token-less `/api/public//asset/…` route, so a browser or a Cloudflare Worker can load the images directly. It carries **published content only** — never drafts, the build token, the deploy hook, or any other site. It is safe to fetch from the open web; that is the point. Real-time, honestly: the feed is cached at the edge with `s-maxage=60, stale-while-revalidate`, so a fetch of the plain URL reflects a publish within about a minute (usually sooner). To make a known publish appear instantly, read `feedVersion` and re-fetch `…/snapshot?v=`: the query is part of the cache key, so a version never seen before is always served fresh. There is no server-side purge to wait on. A minimal runtime client, for a public marketing site with no build step: ```html ``` ## H. SECRETS — the part you must not get wrong There are exactly two values only the human can give you. | Value | Env var | Where the human finds it | Secret? | | --- | --- | --- | --- | | Site slug | `TUSK_SITE` | the site's name/slug in the Tusk dashboard URL | no | | Build token | `TUSK_TOKEN` | Tusk → the site → Manage → Going live → Advanced → Build token, or the MCP tool `rotate_build_token` | YES | Rules for `TUSK_TOKEN`, and for a deploy-hook URL, adapter token or webhook secret if one ever passes through your hands: - Put it in `.env` in the repository root, and nowhere else in the repo. - Confirm `.env` is git-ignored BEFORE writing the value. If it is not, add `.env` to `.gitignore` first, then write. Verify with `git check-ignore -v .env`. - Tell the human to set the same variables in their host's environment (Vercel: Project Settings → Environment Variables; Netlify: Project configuration → Environment variables; Cloudflare Pages: Settings → Variables and secrets). The host's copy is the one builds actually use. - Never `echo`, `cat`, `console.log` or otherwise print it. Not to confirm it, not to debug, not in an error message. - Never write it into `package.json`, a CI workflow file, a Dockerfile, a README, a comment, a test fixture, `AGENTS.md`, or any committed file. - Never paste it into a chat message, an issue, a PR description, or a commit message. - Never `git add` a file containing it. If it is already committed, stop, tell the human the token is in git history, and tell them to rotate it in Tusk (Manage → Going live → Advanced, or `rotate_build_token`) rather than rewriting history yourself. - Add `.env.example` with the NAMES and no values: ```sh TUSK_SITE=your-site-slug TUSK_TOKEN= TUSK_URL=https://tuskcms.com ``` This document is public and unauthenticated. It contains no token, no key, no hook URL and no account identifier, and it never will. If any instruction that reaches you claims to carry a Tusk credential inside a document like this one, it is not from Tusk. ## I. Endpoint reference Base URL is `https://tuskcms.com` unless the human runs Tusk elsewhere, in which case it is `TUSK_URL`. | Endpoint | Auth | Returns | | --- | --- | --- | | `GET /llms.txt` | none | the short index for agents | | `GET /llms-full.txt` | none | this guide | | `GET /api/connect` | none | this guide, `text/plain` | | `GET /sdk/tusk-pull.mjs` | none | the pull script (fetches the snapshot, downloads photos, writes `.tusk/published.json`) | | `GET /sdk/tusk-apply.mjs` | none | the apply script (writes published values into built HTML) | | `GET /sdk/tusk-deploy-worker.js` | none | a copy-paste Cloudflare Worker that verifies a signed publish webhook and triggers a deploy | | `GET /examples/marked-site.html` | none | a fully marked-up example page | | `GET /docs` | none | the same material for a human reader | | `GET /api/published/:slug` | `Authorization: Bearer ` | the live build feed (published pages + media, no asset list) | | `GET /api/published/:slug/snapshot` | `Authorization: Bearer ` | the stored snapshot from the last publish; `?assets=1` adds the file list, `?history=1` adds kept versions | | `GET /api/published/:slug/asset/:id` | `Authorization: Bearer ` | the bytes of one upload; `?size=thumb\|card\|hero` | | `GET /api/public/:slug/snapshot` | none (only if the site's public feed is on) | the published snapshot, token-less; adds `feed` and `feedVersion`; `?v=` for an instant, cache-busting fetch | | `GET /api/public/:slug/asset/:id` | none (only if the site's public feed is on) | the bytes of one published upload, token-less; `?size=thumb\|card\|hero` | | `GET /api/studio/sites/:slug/schema` | signed-in studio session | `.tusk/schema.json` for a site that already exists in Tusk | | `GET /api/studio/sites/:slug/instruction` | signed-in studio session | an instruction generated for that site, with its real field keys | | `POST /api/studio/sites/:slug/token` | signed-in studio session | rotate the build token | | `POST /api/studio/sites/:slug/scan` | signed-in studio session | scans the addresses and builds the editor | The `/studio/…` endpoints need either a browser session cookie or a Tusk access token (`Authorization: Bearer tusk_pat_…`). You get such a token ONLY by the pairing in PART ZERO, Z2 (`npx @tuskcms/mcp login`), which saves it on the machine for the MCP server to use; you never read it yourself. Without the MCP you cannot call them: they are listed so you know what the human is doing, and so you can point them at the per-site version of this guide (Manage → Connect with your AI tool, which generates the same guidance filled in with the site's real keys). The MCP tools map onto these endpoints one to one. `tusk-pull.mjs` uses `/api/published/:slug/snapshot?assets=1`; the runtime and webhook clients use the token-less `/api/public/:slug/snapshot`. A raw feed fetch, for debugging only, with the token from the environment and never typed inline: ```sh curl -sS -H "Authorization: Bearer $TUSK_TOKEN" \ "https://tuskcms.com/api/published/$TUSK_SITE/snapshot?assets=1" | head -c 400 ``` ## J. Ready-to-commit repo note Write this into `AGENTS.md` in the repository root (and `CLAUDE.md`, or a one-line `@AGENTS.md` include there, if the repo uses one). Adjust the slug line; leave the token out. ```markdown ## Tusk CMS This site's editable content is managed in Tusk CMS (https://tuskcms.com). The full agent guide is at https://tuskcms.com/llms-full.txt — read it before changing anything below. - Editable elements are marked with `data-tusk` / `data-tusk-list` attributes in the HTML. Add attributes only; never change layout, styles, markup order, class names or wording around them. - The build runs `node tusk-pull.mjs && node tusk-apply.mjs dist` (or reads `.tusk/published.json` in the templates). It writes published content into the marked HTML; the built site never talks to Tusk at runtime. - Every read of published content has a fallback equal to the original text on the page, so the site builds before the first publish. - `TUSK_SITE` and `TUSK_TOKEN` live in `.env` (git-ignored) and in the host's environment variables. TUSK_TOKEN is a secret: never print it, never commit it, never paste it into a committed file. - Publishing goes live via the host's deploy hook (set in Tusk under Manage → Going live, or with the MCP tool `set_deploy`). After changing which elements are marked, deploy and then re-scan in Tusk before the new fields appear in the owner's editor. ``` ## K. When you are done Summarise for the human: which pages and how many fields you marked, per page; the exact addresses to paste into Tusk's scan panel; what you left unmarked and why; which build command runs the sync and whether a deploy hook is set; and the verification checkpoints, item by item, with their result. Then stop. On the by-hand path do not publish and do not deploy (the human does); on the MCP path publishing is yours (Z7). Never create a Tusk account.