This versioned reference ships inside @patchstack/connect and documents each setup command and its project changes.
Use the package setup flow below for an existing JS/Node application. Work in its package directory; a missing package.json can mean you are in a subdirectory or looking at generated HTML rather than the source project. A page containing HTML can still belong to a server-rendered application. Do not infer that runtime protection is unnecessary from the file extension alone.
For a standalone site made of HTML, CSS, and browser JavaScript, with no package-managed application or server request handler, use the Patchstack Connector directly. Do not create package.json, install a framework, invent build hooks, or add a server just to run Connect, unless the person chooses that in step 1. setup requires an existing package.json; it is not a standalone HTML installer.
-
Use the public site UUID or widget snippet for the correct site in the Patchstack dashboard. An existing
.patchstackrc.jsoncan also supplysiteUuid. Never invent a UUID or use a claim token or API key as the widget identifier. If neither is available, stop before editing the page and give the person these options in plain words, then wait for their choice:- Add the widget with a site from the dashboard. They create an "Application" site in the Patchstack dashboard and paste its site ID or widget snippet. You add the tag in step 2. Nothing else is added to their folder. They get the widget only.
- Create a Node project in the folder and let Connect set it up. No trip to the dashboard first:
setupcreates the site and prints the link that connects it to their account. It addspackage.json, a lockfile andnode_modulesto their folder. Packages they add later are checked for known security problems. A page with no packages of its own has little to check at first, and there is still no runtime protection without a server. Follow "Creating a Node project for a plain HTML site" below. - Stop here. Nothing is changed.
Do not pick for them, and do not create the Node project unless they choose it. A request to install or set up
@patchstack/connectis that choice: follow "Creating a Node project for a plain HTML site" without asking. -
Add one widget tag before
</body>in the page or shared layout. Preserve an existing correct tag. Do not adddata-build-mode: setting it to"false"hides the owner's connect and log-in panels everywhere, including on the person's own machine.<script src="https://cdn.patchstack.com/patchstack-widget.js" data-site-uuid="YOUR_SITE_UUID" defer></script>
Replace
YOUR_SITE_UUIDwith the real public site UUID before saving. Keep credentials out of the page. The public widget reference documents this embed and its options. -
Verify the saved tag uses the correct UUID. If a browser preview is available, reload it and check that the Patchstack Connector appears (a "Connect this website" panel until the site is claimed); otherwise tell the user that the browser check is pending. Do not submit a vulnerability report as an installation test. Save the HTML change and remind the user to publish it when ready; do not deploy it yourself.
Report this as Patchstack Connector installed, with any remaining preview or publishing step. This path does not inventory local JavaScript files or scripts loaded from a CDN, scan npm dependencies, or install runtime exploit protection. External APIs used by the page require their own server-side integration.
Only when the person chose this option in step 1 above. Work in the folder that holds the site's pages.
The pages get a build step, even though nothing is compiled. The build is what runs scan before and mark-build after, and mark-build is what tells a visitor's browser that a page is the live site. Without it the live site is never recognised as deployed. The build copies the pages to dist/ and the stamp goes on the copy, so the pages the person edits stay as they are and their own machine keeps showing the owner panels.
-
Move the pages (every
.htmlfile, plus the CSS, scripts and images they use) intopublic/if they are not there already. Then create the project with its build script before runningsetup, sosetupwires the hooks around it:npm init -y npm pkg set scripts.build="node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true});fs.cpSync('public','dist',{recursive:true})\"" npm install --save @patchstack/connect npx @patchstack/connect setupsetupcreates the site, writes itssiteUuidto.patchstackrc.json, adds the Patchstack widget topublic/index.html, adds"postinstall": "patchstack-connect scan", and wires"prebuild": "patchstack-connect scan"and"postbuild": "patchstack-connect mark-build"around the build. In a hosted builder, scopePATCHSTACK_ENVIRONMENT=sandboxto thesetupcommand, as in "Automated setup". -
Add
distto.gitignorenext to the entriessetupwrote. -
Put the widget on the other pages.
setupadds the tag only toindex.html,public/index.htmlorsrc/app.html. For any other page it lists the widget underMissingand prints the tag to add. Add one tag before</body>on each page, or in the shared layout, exactly as printed — nodata-build-mode. -
When the person names where the site is published, add that host's build settings so it publishes
dist/and runs the build. See "Deploying" below; for Netlify that is anetlify.tomlwithcommand = "npm run build"andpublish = "dist". -
Two
✘lines are expected and need no fix:Runtime protection: no server file found. A plain HTML site has no server to guard. Do not add one.setupleaves a generic guard inpatchstack/, which nothing loads until a server does.Deploy project to protect live app. Publishing the pages is the person's step.
-
End as in "The message you end on", with the dashboard link from the
Next:line. Say that the widget, and a check of the packages the site installs, are active, and that runtime protection is not. To preview it on their own machine, servepublic/(for examplepython3 -m http.server -d public). Do not publish anything yourself.
- Check what is already done with
npx @patchstack/connect guide(read-only). If the project is already provisioned, reuse it — see "Before you start — never install twice". - Install
@patchstack/connectas a runtime dependency with the project's package manager. - Run
npx @patchstack/connect setup. In a hosted builder, scopePATCHSTACK_ENVIRONMENT=sandboxto that command — see "Automated setup". - Finish any
✘line underMissingin the report at the end ofsetup. "Automated setup" names each one. - Tell the person the dashboard link, which parts are active and which are not, to refresh their preview, and to deploy when they are ready.
If your tool will not run the command, see "When your tool will not run this CLI". The sections below describe what each command reads, writes, and sends.
Every command at a glance — what it does, whether it reads your source, what it writes, and what leaves your machine. Full behavior, flags, and edge cases follow in the sections below.
| Command | What it does | Reads your source? | Writes to your project | Sends over the network |
|---|---|---|---|---|
scan |
Provision (or reuse) the site and POST the dependency list for vulnerability matching. Also runs automatically via setup and the install/build hooks. |
No source analysis — lockfile only; node_modules/ is enumerated when no lockfile can be read (e.g. bun.lockb) or when the lockfiles present disagree. It also reads the <title> of the root index.html and the name in package.json, to report what the site is called. During prebuild only, it reads the scaffolded guard and its co-located rules JSON to remove a previous map stamp. |
.patchstackrc.json (public: site UUID + settings); .patchstackrc.local.json (the API key, created owner-only) and a .gitignore entry for it — the CLI says so if it could not add one; during prebuild, removal of a previous _patchstack.build_id from the guard's own rules file so a later build cannot carry stale coordinates; the widget <script> tag in the root HTML shell — only after a successful post; the production marker in a code root shell — before the post, since it needs no site UUID |
Package names + versions; this site's public address and name, where the project or build environment states them |
setup |
One bounded command: scan → manage the widget → install + verify protect → wire the install/build scans. Never runs the project build. |
Local integration reads via scan and protect |
Config, widget tag, production marker, guard/framework/route files, package.json scripts |
Package names + versions and the site's public address and name (via scan); a claim token as a request header, only when you pass one |
map |
Local attack-surface analysis (entry points → inputs → sinks → evidence-backed flows). Never run by another command. | Yes — via the app's own TypeScript; a pre-bundle --upload also locates the co-located rules file imported by the scaffolded guard |
Only the file named by --out; during a pre-bundle --upload, _patchstack.build_id in that existing rules file |
Nothing — unless --upload: structure only (routes, parameter names, the package behind each sink, file:line) plus a SHA-256 identity derived from its policy content. Never source code or env values |
protect |
Install the always-on runtime guard; auto-wire known stacks, or scaffold a generic guard + print a wiring plan. --check verifies the guard is wired (exit 1 if not); --demo seeds a broad sample rule set. Runs automatically only via setup — never by scan, guide, status, or mark-build. |
Reads local wiring/route source for integration; does not produce an attack-surface map | Guard/framework files (e.g. middleware.ts, src/patchstack/, supported Next App Router handlers) |
Nothing |
demo node-serialize |
Production-backed walkthrough: confirm the vulnerable package is present, scan, wait for live rule 18843, install + verify the guard, print test requests. Does not install the package or start/restart the app. |
Local integration reads via scan and protect |
Same files as scan + protect |
scan payload; polls the public Pulse rules endpoint (never the printed test requests) |
demo-guide node-serialize |
Read-only companion: explains the prepare/run/prove/cleanup sequence and prints the next command. | Reads local protection wiring for verification | Nothing | Nothing |
guide |
Print this project's live setup status (done/missing, with tailored commands), then the full guide. --full prints it even when setup is complete. |
Reads local protection wiring for verification | Nothing | Nothing |
status |
Re-print the site UUID + dashboard URL and check whether the site still exists (active / removed / could not verify). | No | Nothing | Site-existence check |
init <site-uuid> |
Optional: pre-seed .patchstackrc.json with an existing UUID. |
No | .patchstackrc.json only |
Nothing |
mark-build |
Ensure the widget tag in built pages, and — on a production build only — stamp the live-site flag + build fingerprint. A local or preview build is stamped with neither, and has a stale marker removed; --production forces it for a build published by hand. Run as a postbuild step. |
No | Build output only (dist/ build/ out/ .output/public/ _site/) — never source |
The same manifest scan sent, plus one word for what it did with the marker — and only for a site already registered |
claim |
Attach the site to a Patchstack account from the terminal: print a link the user opens to sign in (or sign up) and poll (10 min). Whoever approves becomes the owner. Does not rotate the credential. Same result as opening the dashboard link scan prints. Not usable in CI. |
No | Nothing, unless the server issues a credential for a checkout that had none — then .patchstackrc.local.json |
Device-code request + approval poll |
login |
Recover a lost credential for an existing site: print an owner-approval link and poll (10 min). Approving rotates the credential. Not usable in CI. | No | New credential into .patchstackrc.local.json on approval |
Device-code request + approval poll |
uninstall |
Signal Patchstack that the package is being removed: an unclaimed record is deleted, a claimed one is flagged. Does not touch local files. | No | Nothing local | Removal signal |
Only map produces an attack-surface analysis, and only map --upload sends that description. protect reads and edits local source to integrate the guard; its verifier, also used by setup guides, reads the wiring without executing the app. These integration reads transmit nothing. scan additionally reads two declarations the project makes about itself — the <title> in the root index.html and the name in package.json — to report what the site is called; during prebuild it also reads the scaffolded guard and its co-located rules JSON solely to remove a previous map stamp. scan transmits package names + versions, plus the site's own public address and name where the project states them — never source code, file paths, git history, or any environment variable value other than the published URL of this site. scan --install-paths additionally sends where each package sits in the dependency tree; it is off unless you pass it.
- Package:
@patchstack/connect, MIT-licensed, source at https://github.com/patchstack/connect.npm view @patchstack/connectshows the live registry metadata. - What is sent to Patchstack is the dependency list, plus this site's public address and name — the dependencies are read from the lockfile (
package-lock.json,pnpm-lock.yaml,yarn.lock) or, on bun projects (bun.lock/bun.lockb), by enumerating the installed packages undernode_modules/— package names + versions, for vulnerability matching. No source code, no file paths, no git history is ever transmitted.scan --install-pathsis the one exception, and it is opt-in. It adds where each package sits in the dependency tree — repo-relative paths made ofnode_modulessegments, plus a workspace directory name when a workspace pins its own copy. They are read from the lockfile's own keys or from thenode_moduleswalk, never from your source tree: no path to a file you wrote is sent by either form ofscan.- Why it exists: the same package is routinely installed twice at different versions, and without the locations an advisory affecting only one of them cannot be matched to the copy your code actually loads. Node resolves an import by walking up from the importing file, so the location is what distinguishes "you are running the vulnerable copy" from "the vulnerable copy is installed but nothing reaches it". Absent them, every installed version has to be treated as if the app used it — warnings about code you never call, and protection rules pinned to routes that run the safe copy.
- Why it is off by default: it widens what leaves the machine, so it is your explicit choice and not a consequence of upgrading the package. (
mark-buildadditionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable names — e.g.VERCEL,CF_PAGES— never their values.)
- Only
scanlooks for the address and the name. They are resolved in the one code path that reports them, soguide,status,login,uninstall,mark-build,init,protect,demo-guideandmapneither read the host's URL variables nor openindex.htmlorpackage.jsonfor this.setupanddemodo, because both runscan. - The address is the one your visitors use, and, apart from the tool's own
PATCHSTACK_*settings, it is the only env var value read. A site provisioned by a scan from a developer machine has no address, so the dashboard shows a placeholder and Patchstack cannot check that the published page still carries what was scanned.scantherefore sendsurlwhen — and only when — it can know it:urlin.patchstackrc.jsonorPATCHSTACK_SITE_URLif you set one, otherwise the single variable a host publishes to name its own production URL (VERCEL_PROJECT_PRODUCTION_URLon a Vercel production deployment, Netlify'sURLin the production context,RENDER_EXTERNAL_URL,RAILWAY_PUBLIC_DOMAINin a production environment). Preview and branch deployments are excluded, as are hosts that publish no production signal. An address that is not how the public reaches a website is dropped: any IP address (in either family, however it is written), any single-label host such aslocalhostorproduction, and the reserved suffixes (.local,.internal,.test,.invalid,.home.arpa, …). Aurlyou set explicitly that fails those checks is refused with an error rather than replaced by a guess. When nothing qualifies,urlis omitted from the payload rather than guessed. Patchstack only ever applies it to a site that still has no address; it never re-points a site whose address is already real. - The name is read from your project, never from the host environment.
namein.patchstackrc.json(orPATCHSTACK_SITE_NAME) if you set one; otherwise the<title>of the project's rootindex.html(public/index.htmlif there is no root one), read from the file as text — a title your app sets from script is not seen; otherwise thenameinpackage.json, unless it is a template placeholder such asvite_react_shadcn_ts. It is omitted when nothing qualifies, and it only ever fills in a site that has no name yet — a name set in the dashboard is never replaced. - Only
mapproduces an attack-surface analysis. It parses your server source to report your app's attack surface. It runs only when you invoke it and prints to stdout. It transmits nothing unless you explicitly pass--upload, which sends that description of your app's structure to your own site's Patchstack endpoint — never source code, and never without that flag.protectseparately parses supported wiring and route files for local integration, without producing or uploading a map. Aprebuildscan reads the scaffolded guard and rules JSON only to identify and clear the reserved map stamp; it does not analyse them or transmit their contents. scanmakes up to three source edits: the Patchstack Connector's<script>tag, the production marker, and — duringprebuildonly — removal of a previous_patchstack.build_idfrom the existing guard rules file. None runs on--dry-run; all are idempotent."widget": falsedisables the first two, while stale-stamp removal is independent because it prevents old coordinates being attributed to a new build.- The widget tag goes in the root HTML shell — the first of
index.html,public/index.html, orsrc/app.htmlthat exists — and only after a successful post, because it carries the site UUID. - The production marker goes in a root shell that is JSX rather than HTML (e.g.
src/routes/__root.tsx,app/layout.tsx), inside a{/* #region patchstack */}block placed above the widget tag. It is written before the post: it carries no site UUID and needs no network, and build scripts commonly chainpatchstack-connect scan || true, where waiting on the server would mean an offline build silently ships without the flag. The marker is guarded by the framework's own production expression (import.meta.env.PROD, orprocess.env.NODE_ENV === 'production'), so it is inert in dev and preview builds. Without it a server-rendered site has no built HTML formark-buildto stamp, and the widget treats the published site as build mode.mark-buildwrites to build output only (dist/,build/,out/,.output/public), never to source.guide,status, andinitwrite nothing exceptinit's own.patchstackrc.json.
- The widget tag goes in the root HTML shell — the first of
setuprunsscan, thenprotect, then editspackage.jsonscripts: provisioning happens first so the runtime guard can bake the real site UUID. It verifies the resulting framework seam, preserves existing commands, addsscanafter dependency installs and before builds, addsmark-buildafter builds, and uses a direct build chain for Bun. It never runs the project build. If the widget or runtime guard needs a framework-specific manual merge, it prints the exact remaining step instead of overwriting user code.- The package also exposes
protectdirectly (runtime exploit guard; its templates live underdist/protect/).setupinvokes it automatically;scan,guide,status, andmark-builddo not. It writes only local files and auto-wires known stacks — TanStack Start + Supabase (patches the Supabase client +src/start.ts), Next.js (scaffolds or composes middleware and adds request/response checks to supported App Router handlers), SvelteKit (src/hooks.server.ts), Astro (src/middleware.ts), Nuxt (server/middleware/), NestJS (app.use(patchstackMiddleware)in the bootstrap), Fastify (app.register(patchstackFastify)), and Express (app.use(patchstackMiddleware)). On any other stack it scaffolds a framework-agnostic guard undersrc/patchstack/and prints a wiring plan — then you finish the install by importing that guard into your server entry (protectFetch(handler)for a Web-Fetch server, orapp.use(patchstackMiddleware)for Node/Express) and runningpatchstack-connect protect --checkto confirm it is wired (exit 1 until it is). Passing--demoseeds a broad sample rule set (for demonstrations, not production). demo node-serializeis an explicit production-backed walkthrough. It requiresnode-serialize@0.0.4to already be present in the lockfile; it does not install the vulnerable dependency. It runs the same productionscan, polls the configured site's public Pulse rules endpoint until rule18843is served, runsprotect, verifies the generated guard, and prints exploit/benign test requests. It writes the same manifest/widget and guard files as those underlying commands. It does not start/restart the app and does not send the printed requests.mapis local unless you pass--upload. It walks the project's server source (skippingnode_modules, build output and dot-directories; it does not follow symlinks out of the project unless you pass--follow-symlinks), parses it with the project's owntypescript, and prints JSON describing the attack surface: entry points, the inputs each reads, the sinks they can reach (database / file system / process / outbound HTTP) with the npm package behind each, and evidence-backed input→sink flows, each labelled with how the link was established — from an exact read at the sink's own call site, through a transformed or cross-module link, down to the two being present together with no proven link. Static analysis is best-effort, so the output reports the detected surface with coverage counters — not a completeness guarantee. Without--uploadit writes nothing except the file named by--out, and it is never invoked byscan,setup,guide,protect, ormark-build.map --uploadis the only command that sends a description of your source. (The runtime guard can also report rule detections, which carry route paths and parameter names — see "Runtime guard reporting" below.) It POSTs the same JSON document tomonitor/pulse/input-map/<your site uuid>so Patchstack can pin protection rules to your app's own parameter names instead of guessing them. During a pre-bundle build hook it hashes the policy-relevant document (all fields except analyser timing and memory observations), writes the SHA-256 value as_patchstack.build_idin the existing rules file imported by the scaffolded guard, and sends the same value asbuild_id. Outside that lifecycle it sends no identity and changes no file, so any generated scoped rule remains detect-only. No source code, no file contents, no environment variable values. A map with no recognised entry points is still uploaded because its import inventory and coverage limits are evidence; a failure to reach Patchstack is reported and ignored rather than failing your build. Omit the flag and the command stays entirely local.demo-guide node-serializeis the read-only companion. It checks the Host-created site configuration and vulnerable lockfile entry, explains the complete local prepare/run/restart/prove/cleanup sequence, and prints the next exact command. It does not require a deployment and does not change files or contact Patchstack.- Patchstack is not WordPress-only. Connect monitors any JS/Node project — Vite, Next.js, plain vanilla JS, anything with a lockfile.
npx @patchstack/connect guideprints a read-only live checklist of the four steps (install, connect, sync, deploy) and the one next step, with any missing build hook, widget tag or runtime protection wiring listed underMissing. Add--verbosetoguide,scanorsetupfor the technical detail (site UUID, endpoint, environment and what decided it, checksum, files written, the exact package.json lines and the runtime protection checks).- If
.patchstackrc.jsoncontains asiteUuidkey, the project is already provisioned. Reuse that UUID; runnpx @patchstack/connect statusto re-print it and the dashboard URL. Do not delete the file and provision a second site. (A.patchstackrc.jsonwith other keys — e.g. anendpointoverride — but nositeUuidis not provisioned yet; scan normally.) - If
@patchstack/connectis already independencies, skip the install command. If it is only indevDependencies, move it with the matching package manager so production runtimes that prune dev dependencies can load the generated guard. - If the widget script tag (
cdn.patchstack.com/patchstack-widget.js) is already in the layout, don't add a second one —scanalso respects an existing tag: it updates its own managed tag in place and leaves a manual one untouched.
The person who asked for this usually did not write the project and has never installed a package. Installs and builds here can run for several minutes, and a silent wall of build output is the worst part of their first experience of Patchstack. Narrate it, one or two sentences at a time.
- Say what you are about to do, before the slow command. One sentence, no command names: "I'm adding Patchstack to your site — this takes a few minutes because it has to reinstall everything the site is built from."
- Say what the wait is. "It's downloading the pieces your site is built out of. Nothing is broken; this is just slow."
- Explain the product once, in plain words. Your site is built out of hundreds of ready-made pieces of code. New security problems are found in them all the time. Patchstack keeps a list of the pieces this site uses and warns the owner when one of them turns out to be unsafe.
- Say what changes and what does not. Nothing visitors see changes. A couple of small config files are added, and a line that lets Patchstack recognise the site.
- Hand over the link like a next step, not a log line. When
setupprints the dashboard link, say what it is for: "Open this link and sign in — that's what connects the site to your Patchstack account so you can see the reports." - Avoid these words with the user: lockfile, dependency, manifest, npm, registry, UUID, environment variable, build artifact, package manager. Use them in commands, not in sentences addressed to the person.
- Report a failure in terms of what they can do, not what errored. If a step is genuinely unavailable on this platform, say which one and what it means for them, rather than reporting success.
This is a request, not a mechanism: nothing in the install depends on it. Do it anyway — the wait is the only moment where the person is paying full attention.
When setup is finished, the person reads one short message, not a transcript of the install. Keep it to about ten lines, in this order:
- One sentence on the result. "Patchstack is now watching your site." If something did not finish, say what, in one line.
- The four steps, copied from the end of the
setupoutput with their✔and✘. - The next step, from the
Next:line, in plain words, with its link or command. - Refresh and deploy. Refresh the preview to see the Patchstack Connector, and deploy when ready. Say how, in one line: "When you want it live, ask me to deploy it — I'll run the build so Patchstack can recognise the live site."
When the person later asks you to deploy, re-read "Deploying" below before running anything. A deploy that skips the build, or uploads the project folder, publishes the API key and leaves the live site unrecognised.
Leave out the files you changed, the commands you ran, settings, and anything that worked as expected. If the person asks for the detail, give it then. --verbose prints it.
Only when the person asks you to publish the site. Two things decide whether Patchstack recognises the deploy: the build has to run, and it has to know it is the production build.
-
Deploy through the build, and publish only its output. Never pass
--no-build, and never upload the project folder itself: it holdsnode_modulesand.patchstackrc.local.json, which contains the site's API key. Publishdist/for a plain HTML site, or whatever the framework builds. -
A deploy that builds on this machine (
netlify deploy --prod --build, a static folder uploaded by hand) has nothing that says "production" to Patchstack. Prefix that command, and only the production one, withPATCHSTACK_ENVIRONMENT=production:PATCHSTACK_ENVIRONMENT=production npx netlify deploy --prod --buildThe key is in
.patchstackrc.local.jsonon this machine, so nothing else is needed. A preview deploy runs without the prefix. -
A deploy that builds on the host (
vercel --prod, or any git-connected Netlify or Vercel site) labels production by itself, but the host never receives.patchstackrc.local.json. Before the first production deploy, put the key in the host's production settings, read straight from the file so it is never printed:node -p "require('./.patchstackrc.local.json').apiKey" | npx vercel env add PATCHSTACK_API_KEY production --sensitive npx netlify env:set PATCHSTACK_API_KEY "$(node -p "require('./.patchstackrc.local.json').apiKey")" --context production --secretAn app with a server needs this for runtime protection: the guard fetches its rules with the key. Without it the deploy is still recognised as long as the packages have not changed since the last scan, but nothing is protected. Never put the key in a committed file, in a public variable (
NEXT_PUBLIC_*,VITE_*), or in your reply. -
A host that builds the site itself but is not named under "How each environment is labelled" — ChatGPT Sites (
*.chatgpt.site, published from Codex) is one — gives Patchstack nothing that says "production", so its builds reportlocaland the live site is never recognised as deployed. In that host's environment settings for the published site, set two variables:PATCHSTACK_API_KEY, read from.patchstackrc.local.jsonas above, andPATCHSTACK_ENVIRONMENT=production. Set the label only where the published build reads it, never in a committed file, and not in settings a preview or the local dev server also reads. Then publish again: the deployed site keeps serving the build from before setup until it is rebuilt. -
Give Patchstack the live address when the host does not. Patchstack confirms a deploy by reading the build marker off the live page, either when a visitor's browser loads the widget or through its own scheduled check of the site's address. A new site may get no visitors for days, so the scheduled check is often the only confirmation, and it can only read an address Patchstack has. Vercel, Netlify, Render and Railway production builds report their address themselves (see "Package and command behavior"). Anywhere else, including ChatGPT Sites, Lovable or Bolt, a custom domain, GitHub Pages, Cloudflare Pages or a folder uploaded by hand, take the public address from the deploy output or ask the person for it. Then:
- Add it to
.patchstackrc.jsonas"url": "https://your-app.example.com". Use the address visitors use, never a preview or a per-deployment URL. The address is public, so this file can be committed. - Run
npx @patchstack/connect scanonce, so Patchstack learns it.
Patchstack applies the address only to a site that has no real address yet, meaning a placeholder or a builder preview. An address it already holds is never replaced, so if the dashboard shows a different one, the person changes it in the site's settings. A private site cannot be read this way; see the next item.
- Add it to
-
Check the live site after deploying, and tell the person what you found:
curl -s <live url> | grep -ac __PATCHSTACK_PROD__prints1or more.0means the build did not know it was production, and the widget will treat the live site as a preview.- A site published privately answers
curlwith the host's sign-in page, so the check above prints0whatever was deployed — check the status code first (curl -s -o /dev/null -w "%{http_code}" <live url>). On a401,403or a redirect to a sign-in page, ask the person to open the live site while signed in and runwindow.__PATCHSTACK_BUILD__in the browser console: a short build ID means the marker is live,undefinedmeans it is not. Patchstack cannot read a private page either, so its dashboard shows the deploy as reported, not confirmed, until a signed-in visit lets the widget report the build. Tell the person that, rather than describing the deploy as confirmed. curl -s -o /dev/null -w "%{http_code}" <live url>/.patchstackrc.local.jsonis not200. A200means the API key was published: delete the deploy and tell the person.- The owner reaches their dashboard on the live site by adding
#patchstackto the address, for examplehttps://example.com/#patchstack. Visitors never see the owner panels there.
-
Install (skip if already present), matching the project's package manager:
npm install --save @patchstack/connect # package-lock.json pnpm add @patchstack/connect # pnpm-lock.yaml yarn add @patchstack/connect # yarn.lock bun add @patchstack/connect # bun.lock / bun-managed platforms -
Run bounded setup:
npx @patchstack/connect setupThis provisions or reuses the site, manages the widget, installs and verifies runtime protection, wires dependency-install and build scans, prints a dashboard link, and finishes with the same status shown by
guide. Re-running it reuses existing configuration, widget tags, guards, and commands rather than duplicating them.If the request you were given includes a claim token, pass it exactly as given:
npx @patchstack/connect setup --claim-token <token>. The site is then created in the person's own Patchstack account andsetupprints their dashboard link for it — there is no sign-in step to relay. The token comes only from the person's Patchstack dashboard; never invent one, never write it to a file, never print it back. Ifsetupreports that the token had expired or was not recognised, the site is not connected: hand over the dashboard link it prints instead, and tell the person they can copy a fresh prompt from the dashboard.In a hosted builder:
- Run setup with
PATCHSTACK_ENVIRONMENT=sandboxscoped to the workspace process/command. - Ensure the CLI's on-disk edits are adopted into the platform's persisted project state.
- Restart any already-running preview/server process so it loads the guard.
- Do not persist
"environment": "sandbox"in.patchstackrc.json: deployed builds use the same committed files and reportproductiononly when the build platform's own variables say so.
How each environment is labelled:
- A build platform's tier (Vercel, Netlify, Render, Railway, GitLab CI) or a branch named
main,master,production,prod,releaseorlive(Cloudflare Pages and Workers Builds, AWS Amplify, GitHub Actions, GitLab CI without a tier) reportsproduction; a preview, pull request or other branch there reportssandbox, as does the Replit workspace, while a Replit Deployment reportsproduction. - A build in a project the builder generated and builds for itself (Lovable, Replit) reports
productionwithout an override, because the edit preview is a dev server and a build is the publish step — which is exactly why the sandbox label belongs in the workspace process and not in a file. - A scan on a developer's machine, in a CI runner this does not know (
CI=truealone), or on a platform with no such signal reportslocalon its own, and the dashboard shows that app as configured, not deployed.
What runtime protection can report: a positively identified static build reports runtime protection as not applicable. A bundler-only project, including plain Vite, can remain runtime unknown and receive a generic scaffold with incomplete wiring. Report that limitation; do not add an artificial server merely to make the check pass, and never describe a widget or an unwired scaffold as runtime protection.
A
✘line underMissingis yours to finish, not a result to report.setupapplies what it can apply safely and prints the exact edit (or the command that prints it) for anything it would have had to overwrite user code to do. They are: moving@patchstack/connectout ofdevDependencies, the widget tag in a root layoutsetupcould not edit, the production marker on a server-rendered root, and wiring a generic guard into the server entry. The last three are steps 3 and 4 of "Manual setup" below; after the guard one,npx @patchstack/connect protect --checkmust exit 0.Read the report.
setupandscanstart withDone(what this run did) andMissing(what is still missing, each with the one thing to do);guideshowsMissingonly. All three end on the same four steps —Install the Patchstack connector,Connect project to Patchstack account,Sync and monitor in local environment,Deploy project to protect live app— each marked✔(done) or✘(not yet), followed by the one next step. A part the project cannot carry (no build script, no request path for runtime protection) is simply not listed. The CLI does not store whether the site has an owner, soConnectstays✘until a scan hears otherwise from Patchstack; do not report the site as connected from that line alone, and do not report it as unconnected once the person has signed in. In the message you end on, name only the parts that are not active — dependency scans, the Patchstack Connector, the build hooks, runtime protection — and why, one line each; the four steps already say what is done.The widget is part of the install. It is on by default; add it without asking the person whether to. Honour
"widget": falsein.patchstackrc.jsononly when the person set it themselves. If it is there and they did not ask for it, remove it, runsetupagain, and tell them the widget is back on.Finish by telling the user to refresh their preview. The widget loads with the page, so a preview that was already open still shows the HTML from before setup — the widget is missing there until it reloads. Tell them what to expect after the refresh: a site that is not yet connected to an account shows the "Connect this website" panel. A freshly set up site is unclaimed unless setup ran with a claim token. Nothing in the CLI can reach the user's browser, so relaying this is your job. Phrase it as a check rather than a required step: a builder that hot reloads, or a preview server you restarted, may have refreshed it already.
Then tell them to deploy. Setup changes source files, and the deployed site keeps serving its previous build until the next deploy — so visitors get no widget, and on a server-rendered root no production marker, until the user deploys (or hits Publish) again. Say it as a reminder; do not deploy anything yourself.
- Run setup with
Some coding tools refuse to execute a third-party command until the person approves it. In Claude Code,
auto mode's classifier can decline npx @patchstack/connect setup and the installed patchstack-connect
binary on that ground instead of prompting. The command is not the problem; the approval is missing.
Handle it in this order:
-
Stop at the refused command. Do not route around the refusal: do not call the Patchstack API yourself, do not wrap the command in a
package.jsonscript so a later install runs it, and do not add thepostinstall/prebuildhooks first so the next install provisions the site. Each of those runs the command the tool declined, with the person's decision taken away from them. Leavepackage.jsonand the source tree as they were. -
Hand the person the ways forward, with the exact text. Say what the command does in plain words — it registers the site with Patchstack, writes two small config files, adds the Patchstack widget to the page, and adds the protection files and build steps described above — then give them:
-
Run it themselves, in this session. In Claude Code a line that starts with
!runs in their shell and its output lands in the conversation:! npx @patchstack/connect setup. Other tools have a terminal beside the chat; the command is the same without the!. -
Approve it once. In Claude Code,
/permissions→ Recently denied →rretries the command with a manual approval; orShift+Tabswitches to Manual mode, and the tool prompts them when you try again. -
Allow it for good, then ask you to run it again. For Claude Code these rules pre-approve every
npx @patchstack/connect …command and nothing else. They go in.claude/settings.json(shared with the repository),.claude/settings.local.json(this person only), or through/permissions:{ "permissions": { "allow": [ "Bash(npx @patchstack/connect *)", "Bash(npx --yes @patchstack/connect *)" ] } }Other tools keep their own allowlists; the README section "If your coding tool blocks the command" shows the Gemini CLI and OpenCode forms.
-
-
Write the command as
npx @patchstack/connect <command>. A rule matches the command text as written../node_modules/.bin/patchstack-connect setup,PATCHSTACK_ENVIRONMENT=sandbox npx …andnpx --yes patchstack-connect setupare different texts, and the rules above do not cover them. On a developer's machine the sandbox label is not needed anyway: a scan there reportslocalon its own. -
Resume from the output.
setupprints the same checklist, next step and dashboard link whoever ran it, and re-running it changes nothing that is already done. If the person ran it, relay the checklist and the next step from their output as they are. If your tool still will not runguideorstatusfor you, verify from the files instead of guessing:siteUuidin.patchstackrc.jsonmeans the site is provisioned;patchstack-connect scanandpatchstack-connect mark-buildin thepackage.jsonscripts mean the hooks are wired;patchstack-widget.jsin the root shell means the widget is in place;.patchstackrc.local.jsonin.gitignoremeans the credential stays out of the commit. Never construct a dashboard link yourself — it comes fromsetup,statusorclaimoutput. -
claimandloginare the same shape. Both print a link the person opens. If your tool will not run them, the person runsnpx @patchstack/connect claim(orlogin) themselves and you relay the link from their output.
-
First scan — provisions a Patchstack site automatically, writes the UUID to
.patchstackrc.json, and installs the Patchstack Connector's<script>tag into the root HTML shell (index.html,public/index.html, orsrc/app.html) when one exists — or, when the root shell is JSX, the production marker instead. No signup, dashboard step, or UUID is needed up front:npx @patchstack/connect scanIt prints a dashboard link but never opens it. Open that link in a browser to view reports. It also prints what it did about the widget — if it added the tag, reload the preview and confirm the widget appears (the "Connect this website" panel while the site is unclaimed).
-
Wire builds in
package.json:If a lifecycle hook already exists, chain instead of replacing it, e.g.
"prebuild": "existing-command && patchstack-connect scan". Thepostinstallscan reports dependencies added during an iterative sandbox session and covers applications with no build command.Bun-managed projects:
bun rundoes not execute npm-stylepre/postscripts, so wire the build script directly instead:"build": "patchstack-connect scan && <existing build command> && patchstack-connect mark-build".Checking a build yourself: run it through the package manager (
npm run build), never the framework's own CLI (astro build,vite build,next build). Calling the CLI directly skips theprebuild/postbuildhooks, so the build is not scanned, not marked and not reported, and it tells you nothing about what the deployed build will carry. -
Verify the Patchstack Connector — a floating control whose form follows the site's claim state: while the site is unclaimed it is a one-time "Connect this website" panel. It is part of the install and on by default; do not ask whether to add it.
scaninstalls it automatically into a plain HTML shell, a JSX root (Next, Remix, React Router, TanStack Start, Gatsby) or an Astro layout, andmark-buildcarries it into built HTML. Only whenscanreported that it found no editable shell at all — a root whose head mechanism is not a plain script tag, e.g. Nuxt'suseHead— add the one-liner it printed to the root layout yourself, just before</body>(never a JS entry point), readingsiteUuidfrom.patchstackrc.json. On those same roots the widget also needs the production marker above the tag —scanadds it automatically to a JSX root or Astro layout, and prints it to paste when it finds no anchor. A server-rendered site without the marker serves the build-mode claim flow to its visitors:<script src="https://cdn.patchstack.com/patchstack-widget.js" data-site-uuid="<SITE_UUID>" defer></script>
Framework-specific placement patterns: https://cdn.patchstack.com/llm.html. The site UUID is public by design — it ships in client-side HTML and is not a secret. The credential is the opposite, and
scanwrites it for you — there is no manual step, and you should never invent or ask the user for this value:
apiKey(in.patchstackrc.local.json, which is git-ignored; alsoPATCHSTACK_API_KEY, WP format{secret}-{oauth.id}) — one credential for both paths. It authenticates Pulse ingest (manifest, attack-surface map, package removal, rule detections), where it is exchanged for a short-lived token rather than sent directly, and block-log reporting through the connectorPOST /api/logs/log, so "Threats blocked" fills in the dashboard.
A production build of a registered site that has no credential — a hosted builder's publish, which builds from the committed project and so never sees the git-ignored key file — cannot send its manifest. scan and mark-build then POST monitor/pulse/build/<your site uuid> instead, with no credential and exactly three fields: the manifest checksum, environment: "production", and (mark-build only) the marker result. No package names, no address, no name, no environment variable values. Patchstack accepts it only when the checksum is the build it last scanned for this site, and shows the build as reported rather than deployed until the live page is seen running it. A publish that changed your packages is refused; set PATCHSTACK_API_KEY for that build.
It is server-only. Never put it in the widget tag, client bundles, or public env vars (NEXT_PUBLIC_*, etc.). Prefer PATCHSTACK_API_KEY in production; the git-ignored .patchstackrc.local.json is fine for local DX. If it is lost, npx @patchstack/connect login recovers it via dashboard approval — do not delete the file and re-provision, which would create a second site. Opt out of reporting with PATCHSTACK_TELEMETRY=off. If the project must not carry the widget, persist "widget": false in .patchstackrc.json; otherwise the next scan re-adds it.
A pulseAuth field is still honoured if a project has one, and PATCHSTACK_PULSE_AUTH still overrides it, for deployments that authenticate Pulse ingest with a different credential from block-logs. Do not add either yourself: they are unnecessary when the two share one credential, which is the default.
-
Install and verify runtime protection:
npx @patchstack/connect protect npx @patchstack/connect protect --checksetupperforms both steps automatically. The explicit commands are for manual setup or repair. If verification reports a generic or existing framework seam, follow Completing guard wiring, complete the source edit and re-run--check; do not report protection as active until it exits successfully.Next.js:
protectuses the installed TypeScript parser to compose supported middleware while preserving its original routing scope, and edits supportedapp/**/route.ts/route.jshandlers to check requests and filter returned responses. It writes a shared server-onlypatchstack.nexthelper alongsidepatchstack.rules.json. Unsupported exports, complex matchers or handlers are left unchanged and reported by--check; re-runprotectafter adding routes. An existingproxy.ts/proxy.jsrequires manual integration: no competing middleware is scaffolded and--checkreports the gap. Middleware alone cannot filter downstream page bodies. Rendered pages, Server Actions and Pages API response filtering are not verified by this adapter. Keep Next.js patched: a framework middleware bypass also bypasses a guard in middleware. Edge middleware needsPATCHSTACK_API_KEYin the server environment; it cannot read.patchstackrc.local.json. Do not put credentials in public variables or commit them. Source checks do not verify rule delivery or blocking in the running deployment.--checkreads the app's source. It can establish that the guard is imported and called on a request path; it cannot establish that a request ever reaches it — an app can wire the guard onto one server and serve traffic from another, and that passes. To settle the difference there is an opt-in check that starts the application:npx @patchstack/connect protect --check --runtimeIt launches the project's entry with
node, moves the HTTP listeners that process opens to an ephemeral loopback port, sends one request per listener carrying a per-run challenge, and reports whether the scaffolded guard seam answered it. Exit0runtime traversal reached the seam,1a listener answered and the seam did not,2it could not be established — neither a pass nor a failure, with the structural checks still standing on their own.Exit
2is the answer for everything this cannot speak for, and the reason is always printed. The common one is an entry that needs the project's own toolchain (a TypeScript entry, a framework launcher, a watcher, another runtime, anything reached through a package manager), which this never installs, builds or invents. The others are about scope: the run answers for one process, one thread, and one discovery window. If the app attempts to start another process, the launch is refused and the answer is2. A child can daemonize after it starts without declaring that in its launch options, so allowing it would make the end-of-run process-group cleanup a claim the verifier cannot establish. The app seesEPERM. A worker thread is also2: it inherits the listener handling, but it cannot report back, so its listeners can be neither counted nor asked. So is a listener that bound an address other than loopback, one that cannot be probed, and anything the app opens after the discovery window has closed — the app is asked to stop and its acknowledgement is what closes that window, so a run that never gets one is2as well. An inheritedNODE_OPTIONSthat would run code before the listener handling is in place — a--requireor--importin your environment — is2too, and is refused before the app is launched rather than after.A worker handed a replacement environment that does not preserve the propagated
NODE_OPTIONSis refused outright, because it would not load the listener handling. The app seesEPERM, and the run reports2.What a pass says is exactly: runtime traversal reached the scaffolded guard seam. It does not say rules were delivered, that the deployed app is wired, or that ordinary traffic is blocked.
Nothing else runs the application.
protect,protect --check,setup,guide,scan,statusandmark-buildonly read and write files;scanandmark-buildalso report the dependency manifest they read. -
Commit
.patchstackrc.json, the updatedpackage.json, the guard/framework source changes, and the layout/HTML file carrying the widget tag (and the production marker, whenscanwrote one into a JSX root or Astro layout), so every developer and CI run reports to the same site.Do not commit
.patchstackrc.local.json. That file holds the API key issued at provision; the scan writes it and adds it to.gitignore, and tells you if it could not..patchstackrc.jsonholds only the site UUID and settings, and the UUID is public by design — it ships in the widget tag in served HTML. -
Connect the site to a Patchstack account. The site is monitored either way, but its vulnerability reports are only visible once it is attached to an account, and an unattached site stays claimable by anyone who loads the page — the site UUID ships in the HTML and claiming is first-come. Three routes reach the same place; tell the user all three and lead with the first, which needs no terminal and no copied URL:
- The widget's "Connect this website" panel, already on the preview. While the site is unclaimed the widget serves this panel, and signing in there attaches the site. On a published build it is hidden from visitors; the owner reveals it by appending
#patchstack(or?patchstack) to the live URL. - The dashboard link the scan printed — open it in a browser and sign in.
npx @patchstack/connect claimfrom the terminal, which prints a link to sign in with and then attaches the site.
- The widget's "Connect this website" panel, already on the preview. While the site is unclaimed the widget serves this panel, and signing in there attaches the site. On a published build it is hidden from visitors; the owner reveals it by appending
Use this procedure when scaffolding leaves a manual step, and review automatic edits against it too. The printed entry candidates and framework names are hints. A generated guard or a successful source check is not proof that every deployed route passes through it.
- Find what actually serves requests. Read the application's package, installed framework version, start/build scripts, deployment adapter and existing server hooks. In a workspace, inspect each deployed package separately. Trace the production entry to pages, APIs, loaders/actions, RPC and server functions; include separately deployed functions and additional listeners. UI dependencies and Vite alone do not establish a server or a static-only deployment. For a static export, establish that no request handler is deployed before reporting runtime protection as not applicable.
- Choose the shared request entry. Prefer the framework's server hook or the deployed server's
outer handler over individual routes. Read the generated guard's exports and calling convention.
protectFetchwraps a handler receiving a WebRequestand returning aResponse; it is not a wrapper for arbitrary framework contexts or response-writing callbacks. Preserve handler arguments, runtime bindings and any required receiver. Do not pass Connect middleware directly to Koa, Hapi, Adonis or other incompatible middleware APIs. If the conversion cannot be established, leave that entry unwired and name the missing integration rather than inventing an API. - Compose a minimal edit. Preserve authentication, redirects, rewrites, cookies, headers, errors
and existing matchers. Inspect matcher exclusions for skipped application routes. Keep the guard
server-only and return its blocking response before calling application code; call the original
handler once for an allowed request. The Express adapter's guard uses parsed
req.bodyand belongs after the body parser, before routes. The generic Node guard reads the stream and belongs before body parsers. Preserve raw-body webhook verification and test uploads and streams before claiming those paths work. Do not edit generated build output, replace an existing hook wholesale, add a server to a static app, or rerun scaffolding over a manually adapted guard without reviewing what it will write. Check the diff for duplicate registrations and unrelated changes. - Verify the integration and its limits. Run
protect --check, then the app's existing typecheck, build and relevant request tests. In a local test environment, check an allowed request and a controlled blocking case for each independent entry and representative page/API/action path; verify the blocked request does not reach the handler. Include existing authentication, redirects, body handling and error behavior. The opt-in--runtimecheck described above probes the guard seam; it does not prove route coverage or rule effectiveness. Record an unsupported probe or an unrecognized custom seam as unverified. Never add marker comments, dummy imports or unused calls merely to make the source check pass.
These are navigation hints for agent-assisted integration, not additional automatic adapters or a compatibility guarantee. Confirm the installed version and deployment mode before choosing a hook. The linked framework documentation describes its lifecycle; the generated Connect guard determines which integration API is available.
| Framework or UI layer | Server entry and coverage question |
|---|---|
| React | Find the hosting framework or custom server. A browser component or client router is not a request guard. |
| Vue | Inspect the SSR host or separate API; component setup and router navigation guards do not guard server requests. |
| Angular | Distinguish browser/prerender output from the deployed SSR server; inspect server.ts and its HTTP adapter. |
| Svelte | Determine whether this is a browser bundle, SvelteKit or a custom SSR host before choosing a server hook. |
| Preact | Guard the host invoking SSR or APIs; a render function alone is not the shared HTTP entry. |
| Solid | Separate the UI library from SolidStart or a custom server; keep protection out of client components. |
| Qwik | Inspect Qwik City and the deployment adapter; component resumability does not identify the request entry. |
| Ember | Inspect the deployed backend or SSR host separately; browser routes and the development server are not production coverage. |
| Next.js | Inspect root or src/ middleware/proxy, matchers, APIs and Server Actions. Next 16 renamed middleware to proxy; Connect scaffolds middleware.ts. Do not leave competing files or assume its source check validates proxy.ts. |
| Nuxt | Inspect the configured server directory and Nitro server middleware, not client navigation middleware. Distinguish a server deployment from generated static output. |
| SvelteKit | Compose the existing server handle hook; check endpoints, actions, prerendering and the deployed adapter. |
| Astro | Compose onRequest in server middleware; distinguish execution during prerendering from on-demand routes behind an adapter. |
| Remix | Inspect the adapter around createRequestHandler; cover document requests, loaders, actions and resource routes, not only entry.server rendering. |
| React Router | Determine library versus framework/SSR mode. Inspect the server adapter and version-specific server middleware; client middleware cannot guard loaders/actions on the server. |
| TanStack Start | Inspect the server entry and global request middleware, including server functions. The automatic TanStack/Supabase adapter matches a particular project layout, not every Start app. |
| SolidStart | Inspect configured server middleware and adapter; verify API and server action paths separately rather than assuming rendering middleware covers them. |
| Qwik City | Inspect deployment entry and request middleware, including endpoints, loaders and actions. Confirm route/layout scope and static output. |
| Gatsby | Static pages need no request guard, but src/api functions and SSR deployments need their own server entry review. |
| Docusaurus | Confirm static output; inspect any separately deployed API or custom server without adding a guard to browser code. |
| Eleventy | Confirm static output; review accompanying functions or a custom server separately from build-time templates. |
| Express | Register the generated Express guard after its body parser and before routers, for every app that actually serves traffic. |
| NestJS | Inspect NestFactory.create and the selected HTTP adapter. Express-style middleware is not proof of Fastify compatibility or microservice/WebSocket coverage. |
| Fastify | Register the generated plugin in the root scope before route plugins; inspect encapsulation and every server instance. |
| Hono | Inspect the deployed Fetch entry and preserve environment/context arguments; check mounted apps and any other exported handlers. |
| Koa | Inspect the Node server around app.callback() or use a verified Koa integration; (ctx, next) is not Connect's (req, res, next). |
| Elysia | Inspect the actual Bun/Node/Fetch deployment entry and plugin scope; a local hook need not cover sibling routes. |
| AdonisJS | Inspect the server middleware stack in start/kernel.ts; named route middleware alone leaves other routes outside its scope. |
| Hapi | Inspect server lifecycle extensions and payload timing; adapt request/response semantics instead of passing Express middleware to server.ext. |
| Nitro | Inspect installed major, configured server directories, middleware and deployment preset; directory scanning conventions differ across versions. |
| Strapi | Inspect configured global Koa middleware; route middleware and Document Service middleware do not establish whole-server HTTP coverage. |
Return a short handoff in the current conversation for the person or their coding agent: framework and version, deployment mode, project-relative server entries, files changed, exact failing check, the remaining edit, and the routes or services whose coverage is unknown. Include commands actually run and their results; omit credentials and environment values. If no server entry can be established, say so and identify the missing deployment information. Do not invent one to clear a checklist.
Keep automatic scaffolding, source verification, local request verification and deployed coverage separate in the report. HTTP middleware does not establish coverage of jobs, queues, WebSocket messages or services deployed elsewhere. Connect prints a local plan; it does not automatically contact another AI model. A framework or hosting upgrade requires this review again.
- Never invent or guess a UUID — the scan provisions it, the widget silently no-ops on a fake one.
- Never invent or guess a claim token either. One is only ever handed to you by the person, from their own Patchstack dashboard; pass it with
--claim-token(orPATCHSTACK_CLAIM_TOKEN) and nowhere else — not into.patchstackrc.json, not into a committed file, not into your reply. - The CLI never opens the dashboard link and never asks for Patchstack credentials.
- Label hosted workspace scans with
PATCHSTACK_ENVIRONMENT=sandboxin that process only. Leave production builds unset (a platform's own tier or production branch name, or the hosted builder the project belongs to, makes the build reportproduction; a developer machine or a CI runner this does not know reportslocal) and never commit a sandbox label into files shared with production. - If a step fails, stop and report it. Don't proceed with placeholders.
- If your tool refuses to execute the CLI, stop and hand the command to the person — see "When your tool will not run this CLI". Never work around a permission refusal.
- CI never has the credential in a file:
.patchstackrc.local.jsonis git-ignored by design, so setPATCHSTACK_API_KEYas an env var there (andPATCHSTACK_SITE_UUIDtoo where.patchstackrc.jsonis also absent). Precedence for the site UUID and settings: CLI flag → env var →.patchstackrc.json. For the API key: env var →.patchstackrc.local.json→.patchstackrc.json(where installs made before the split still hold it).loginis interactive and refuses to run in CI, so CI always takes its credential from the environment.
Some protection rules are scoped to your app's own coordinates — a specific route and a specific
field name, which Patchstack learns from map --upload. A coordinate is only true of the source it was
read from: rename the field two deploys later and the rule addresses something that no longer exists,
while still reporting as active protection. Coverage that is not there is worse than a known gap.
Such a rule carries a build_scope naming the policy map its coordinate came from, and it blocks only
when Patchstack confirms those coordinates belong to the map carried by the guard now running. Three moving
parts:
scan, duringprebuild, removes any previous_patchstack.build_idfrom the guard's own rules file before the bundler runs. It never does this frompostinstall, a manual scan, or--dry-run.map --upload, in that same pre-bundle lifecycle, derives a SHA-256 identity from the map's policy content (excluding analyser timing and memory observations), writes it into the existing rules file the scaffolded guard imports, and sends the same identifier beside the coordinates. It refuses to guess among several guard bundles and never creates one.- The runtime guard presents it on the rules request it already makes, as
X-Patchstack-Build, on requests that already carry your site credential. Patchstack answers on both full and304responses withX-Patchstack-Build-Match; a match also names the confirmed map inX-Patchstack-Build-ID. Only that explicit confirmation lets a scoped rule block.
Presenting an identifier is not confirmation. A missing verdict, an unrecognised one, or one naming a different map all leave scoped rules detecting only — which is also what happens against a Patchstack that does not implement the verdict yet.
The identifier is the map's content digest, not a repository or platform value. Two different maps
cannot be treated as the same merely because they came from the same commit, and a dirty working tree is
bound to the coordinates actually read. A build that does not run map --upload before bundling simply
has no identifier, which is an ordinary outcome rather than an error.
What happens without one: ordinary rules keep enforcing exactly as they do now, including versioned
rules — only scoped rules drop to detect-only, with the reason reported through onError. The same
applies to a cached ruleset fetched for a different map, one cached before this mechanism existed,
and a ruleset served by the older token-authenticated endpoint, which does not implement the verdict.
Rules you pass to createProtection yourself follow the same rule and not a looser one: supplying a
rule locally establishes that you intend it, not that its coordinate still describes the code running.
A scoped rule you supply blocks when it names the map identity this guard reports (buildId), or when you
set trustLocalRuleScope: true to take responsibility for the match.
Nothing here is sent unless you have already opted into map --upload. The identifier is a one-way
digest of the map — never a message, an author, a diff, a branch name, source text, or environment value.
The runtime guard (protect) can report the rules that matched, so the dashboard can show what a rule
would have stopped while it is still in dry-run. Two separate paths, with different triggers:
-
Blocked requests go to the connector
POST /api/logs/log, the same path the WordPress plugin uses, and fill in "Threats blocked". This runs when the guard is holding anapiKeyand a rule blocked a request. The credential is first exchanged atPOST /oauth/token(client credentials) for a bearer token; theapiKeyitself is not sent to the log endpoint. Disable withPATCHSTACK_TELEMETRY=off, orreportFirewallLog: falseincreateProtection. -
Every rule that matched goes to
monitor/pulse/detections/<your site uuid>— including matches that blocked, which are reported on both paths. It exists because a rule carryingdry-runblocks nothing, so without it nothing distinguishes a rule that is protecting from one that is quietly wrong.This is on by default for a site enrolled with Patchstack that is running Patchstack-delivered rules, and off otherwise. Specifically, it requires all of: a provisioned site UUID, rules that came from Patchstack rather than from a local bundle, and a resolvable credential. A local install, or a guard running its own
rules, sends nothing.Switch it off with
PATCHSTACK_REPORT_DETECTIONS=0, orreportDetections: falseincreateProtection, orPATCHSTACK_TELEMETRY=offwhich covers all telemetry.reportDetectionsis an opt-out only — passingtruecannot switch reporting on for a site that is not enrolled.protection.detectionReportingnames the current state locally, so a guard that is not reporting says which reason applies:on,disabled-by-config,disabled-by-telemetry-opt-out,not-enrolled,no-managed-rules, orunavailable-no-credential.How the state reaches Patchstack, and what that costs on the network. The state travels as a header on the rules request the guard already makes — no extra request for it. Two of the six never travel:
not-enrolledmakes no site-addressed request at all, andunavailable-no-credentialcannot produce an authenticated one, and the header is withheld from unauthenticated requests. Those two are local diagnostics only.There is one case that does add a request. The header is set before the rules request finishes, so a guard booting with no cached rules declares
no-managed-rulesand then receives managed rules on that same response. When that happens it sends one immediate POST to the detections endpoint containing the corrected state and no detections at all — an emptydetectionsarray plusreporting_state. It is sent once per process, only when the state changed, and never when the guard already had cached rules. Without it, a guard with rule refreshing switched off would leave Patchstack holding the pre-resolution answer for the life of the process.
What a detection report contains, per matched rule — on every phase, whatever fired it: the rule id, the revision of the rule when the bundle carried one, the identifier of the rule bundle in use, which phase matched, the rule's category and the action it declares, whether it was enforced, which call it belongs to, the request path with the query string's values removed, that query's parameter names, the method, and a timestamp.
Which call it belongs to is a token your guard mints and repeats on every rule that matched the same request or the same outbound call. It exists because two rules matching one call is ordinary rather than unusual — a rule that enforces and a rule that only observes are meant to match the same thing — so without it, adding these reports up counts one call more than once.
Nothing about the request goes into it: not the address, not the path, not a header. It is drawn from
your runtime's randomness where that exists, and from the clock plus Math.random where it does not,
which is how this package already makes its own instance id. It is never a secret and never a boundary —
nothing is authorised by holding it — so what it has to do is not collide between two calls. A request and the response to it share one token; an
outbound call gets its own, because an outbound attempt is a thing in its own right and one made outside
any request has no request to belong to.
The category and the declared action say what KIND of rule matched — "a secret-exposure rule that
redacts", "an SSRF rule that blocks". Both are read from the rule your guard was served, and both are
null when that rule declares neither: a rule whose class nobody can state is reported as one, not
filled in. Neither is the same as enforced, which is whether the rule acted on this particular
request: a rule declaring block while only observing reports exactly that, and that is what a
detect-only deployment consists of. Each batch also carries a count of reports dropped when traffic outran the flush,
so a partial sample is not read as a complete one.
Two fields depend on the phase, because one kind of detection has a client and the other does not. A
request or response detection also carries the user agent,
and the client address together with where that address came from.
An egress detection — a rule that fired on a call your application made outbound — carries neither:
the call was your application's own, so there is no visitor to attribute it to, and those fields read
null and unavailable rather than being guessed at. "What values a report can contain" below says the
same thing about captured evidence.
Every field is bounded in size, and an event that had to be shortened says so.
truncated lists which fields were shortened.
parameters_total records how many parameters the rule reads, when a rule reads more than the event
names.
query_keys_total records how many query parameters the request carried, counted as DISTINCT names,
when it carried more than the event lists — a parameter repeated three times is one name to look up. The
names themselves are the ones the guard addresses a parameter by, so ?first+name=x is reported as
first name. Both appear only when something really was shortened, so their absence is not a claim of its own —
and a shortened route or rule id is marked rather than passed off as complete, because a reader must not
use one as a key believing it names the whole thing.
Delivery is retried, up to four attempts per batch, with exponential backoff and jitter, honouring a
Retry-After header when the endpoint sets one. Only failures worth retrying are retried — unreachable,
rate-limited, or a server error; a batch that was refused on its merits is not sent again. Every attempt
of one batch carries the same Idempotency-Key header, and a different batch carries a different one, so
a redelivery is identifiable as the same batch rather than a new one — an acknowledgement can be lost
after the server has already taken a batch. One request is in flight at a time, so a slow endpoint slows
the queue rather than opening more sockets; each attempt is abandoned after 10 seconds, so a request that
never settles cannot hold that slot; and a batch that exhausts its attempts is dropped and counted rather
than retried forever. Stopping a guard makes one last attempt at whatever is outstanding and counts
anything it could not send. stop() returns a promise that settles once the reporters have finished or
been given up on, so a shutdown handler can await protection.stop() instead of racing the last batch
against process exit. Each reporter has its own budget, and when it runs out that reporter is ended: its
requests are aborted, it starts nothing further, and it discards what it was holding. An abort is a
request to stop, not a guarantee — a transport that ignores it is detached rather than completed, so
"resolved" means the reporter is finished with it, and a runtime that kills the process still wins
regardless. Every detection event ends up delivered, refused or dropped and is reported in the health
counts. The block log keeps the same kind of local counts: protection.blockLogHealth() returns how many
block records were accepted, delivered, failed or dropped, and how many are still queued. Those counts
carry no request data and stay in your process.
The client address is reported with its provenance, because an address is only as trustworthy as
whatever supplied it. client_ip_source is one of runtime (the address the transport observed),
trusted-proxy (read from a forwarded header, through peers you declared via trustedProxy), or
unavailable. When it is unavailable the client_ip field is omitted entirely rather than sent
empty, so a missing address cannot read as a failed lookup of a real one. A forwarded header is never
trusted implicitly: with no trustedProxy policy the address is whatever the transport observed, and in a
runtime that exposes no transport peer there is no address to report at all unless your code supplies one
with peerAddress (below).
The guard resolves the client address itself, once per request, and shares that one answer with rule matching, block logging and detection reports — so those cannot disagree about who a request came from. Two consequences if you are upgrading:
- Express and Node: forwarded headers are no longer read implicitly. Earlier versions took the
address from
X-Forwarded-For,CF-Connecting-IPorX-Real-IP(the Node guard), or fromreq.ip(the Express guard, where it reflects Express's owntrust proxysetting). Neither source can be verified by the guard, and any client can send those headers, so both guards now read the transport peer. If your app runs behind a proxy or load balancer, addresses will now show as the proxy's until you declare your proxies withtrustedProxy(below) — which affects attribution in reports and any rule matching onserver.iporREMOTE_ADDR. - Fetch runtimes report no address unless you supply the peer. A WHATWG
Requestexposes no transport peer, so a Fetch guard (Workers, Deno, Bun, edge) has nothing to observe on its own, and no forwarded header is accepted in its place:client_ip_sourceisunavailableand no address is sent. Earlier versions reported the forwarded header here, so an address-scoped rule that appeared to work on such a runtime was matching a client-supplied value. Where your runtime does know the peer, passpeerAddress: (request, ...handlerArgs) => string— for example(req, info) => info.remoteAddr.hostnameon Deno, or(req, server) => server.requestIP(req)?.addresson Bun. It receives the request and the arguments your handler was called with (fetchGuard()(request, ...args)andscreenResponse(response, request, ...args)pass them on), and that address then counts as the transport peer, including fortrustedProxy. WithtrustedProxyset and no peer supplied, the guard warns once, because the policy can never apply.
trustedProxy is the only way to make a forwarded header count. It takes the proxies you actually run —
{ peers: ['10.0.0.0/8'] }, or { hops: 1 } to trust that many hops in from the peer, plus optional
header and isTrusted — and the chain is then read from your application inward, stopping at the first
hop you have not declared. There are no built-in provider presets: a header a provider sets is
indistinguishable from one a client sent unless you say which peers may set it.
The parameter names are identifiers, and they name the request region they refer to — post.title,
get.redirect_to, cookie.session, server.HTTP_AUTHORIZATION. So a rule that inspects a cookie or an
Authorization header sends that cookie's or header's name. They are read from the rule's own
definition, not from your traffic, so they describe what is being screened rather than what any request
contained.
A request or response detection carries the request's method and path, the query string's parameter names, the user agent, the client address with its provenance, and a timestamp.
An egress detection — a rule that fired on a request your application made outbound — carries the
outbound method and path, the query's parameter names, and a timestamp. It carries no user agent and no
client address: the call was your application's own, so there is no visitor to attribute it to, and
those fields read null and unavailable rather than being guessed at.
Beyond that baseline, either can include the values of the parameters a rule names — and nothing else. Counting that a rule fired is not enough to act on it: whoever triages a detection still has to decide whether the request was really an attack, and for that they need to see what the rule saw.
A rule earns each permission by naming what it reads. What may be captured is derived from the rule itself, never configured per site:
- a rule naming a parameter (
post.title,cookie.session,server.HTTP_AUTHORIZATION) permits that parameter's value, because the rule was written to inspect it; - a prefix (
post.field_*) permits the values of keys that match, and no others; - a rule reading
raworall— the whole request — permits nothing at all, so the broadest rules grant the narrowest capture; - response values are never captured: the phase that reads them exists to redact secrets, and capturing them would collect the very values that redaction stops leaving;
- raw request bytes need an explicit, reviewed opt-in on the individual rule, and are then limited to a short prefix of the body.
Everything is bounded, and the bounds report themselves. At most 10 values per detection, at most 512 characters each, and at most 5 values from any one prefix. A value shortened to fit is marked; values a bound left out are counted; a value refused because it was not a plain string, number or boolean is counted separately; and a read that failed is counted as a failure rather than as absence — so a short list is never mistaken for a complete one.
A capture.plan identifies the permissions, not the rule. Each report carries a capture.plan reference derived
from the permissions themselves — which parameters, which prefixes, which bounds — so what a given report
was permitted to include can be established after the fact, without the rule in front of you. It
identifies the PERMISSIONS, not the rule: two different rules that read the same parameters share one
reference, and the rule document is identified by rule_id with rule_revision.
Capture is the union of everything the rule reads, not only the condition that matched. The engine
reports which rule fired, not which of its conditions did, so a rule reading post.title and
cookie.session permits both values whichever one triggered the detection. A rule scoped to one parameter
captures one; a broad rule captures what it is broad about.
One header value always travels, whatever the rule names: the User-Agent — on a request or response detection, where there is a client to attribute. It is part of the baseline above, because attribution is what this channel is for and a detection without it cannot be told from another client's. It is the only exception to the rule-scoped policy, and the only header value sent without a rule naming it.
What a report never contains: the value of any parameter the matched rule does not name — the User-Agent above excepted; any response body, header or status value; the request body, other than the reviewed raw prefix above; and anything at all from a rule that reads the whole request without that opt-in.
One qualification on the query string. The exclusion above is about baseline URL metadata: route and
query_keys describe a URL without disclosing what was in it. It is not a promise about captured
evidence. A rule that names egress.url reads the outbound URL, so its capture carries that URL as the
rule read it — query values included. That is the rule-scoped policy working as described, not an
exception to it: the rule named the parameter, so the parameter's value travels. The value recorded is the request as the
engine resolved it — URL- and entity-decoded — and not the result of a rule's own further mutations.
Reports are batched, capped in memory, retried a bounded number of times, and dropped if Patchstack cannot be reached — a reporting failure never delays or fails a request.
The endpoint needs a credential, so an enrolled site with none resolved starts nothing: the guard warns
once at boot and protection.detectionReporting reads unavailable-no-credential instead of on.
When reporting is on, protection.detectionHealth() returns local counts — detections attempted,
acknowledged, refused or unreachable, dropped for queue pressure — and the time of the last
acknowledgement. Counts for the state POST described above are kept separately under capability, since it
carries no detections and would otherwise read as one. Those counts stay in your process; nothing extra is
sent to report them.
protection.stop() stops everything the guard has running in the background — the rule-refresh loop, the
block-log reporter, the detection reporter — and flushes what is buffered. With egress: true it also
removes this guard's outbound-request screening; once no guard in the process is screening, fetch and
node:http/node:https are restored. Call it on shutdown; it is safe to call twice.
protection.stopRefresh() stops only the rule refresh: the reporters and outbound-request screening keep
running.
When more than one guard in a process has egress: true, an outbound call is checked by each of them and
refused if any one refuses it. A host listed in one guard's allowHosts is still refused when another guard
refuses it.
Two more endpoints the package can call, for completeness:
GET monitor/widget/settings/<your site uuid>— howstatustells "this site was deleted on Patchstack" apart from "still active". It sends no credential and nothing about your project; the site UUID in the path is the whole request.GET api/get-rules/3— the older rules path, used only when the guard is configured with atokeninstead of a site UUID. The zero-configuration flow provisions a site UUID and usesmonitor/pulse/rules/<uuid>instead, so this is unreachable unless you passtokenyourself.
npx @patchstack/connect statusre-prints the site UUID and dashboard URL, and checks whether the site still exists on Patchstack (Site status: active / removed / could not be verified).npx @patchstack/connect protect --checkverifies from the source that the runtime guard is connected to the request path. It does not run the app.npx @patchstack/connect protect --check --runtimeadditionally starts the app on a loopback port and sends it one request, to establish that a request reaches the guard seam. Opt-in, and the only command that runs the application; exit0/1/2as described in step 4.- Load the site in a browser — the widget should appear, as the "Connect this website" panel while the site is unclaimed. Refresh a page that was already open before the tag was added: the widget only loads with the page.
- On the deployed site, the button appears only after a deploy that includes these source changes.
These are two independent states — never conflate them:
- The site record on Patchstack (remote). Deleting the site in the dashboard or through the widget's uninstall flow removes it. Reporting stops and the widget stops rendering, but nothing in the project changes.
- The local integration (this repo): the widget
<script>tag,.patchstackrc.json, the@patchstack/connectdependency, the runtime guard files, and the build hooks.
Local files alone cannot tell you whether the site was removed from Patchstack. Run npx @patchstack/connect status and read the Site status line, then answer with both states. For example, when the site was removed but the local files remain, say: "The site itself was removed from Patchstack — reporting has stopped and the widget no longer renders. The local integration code (widget tag, .patchstackrc.json, .patchstackrc.local.json, the dependency) is still in the project; want me to remove it?" — not "Patchstack is still installed."
A scan provisions the site without an owner. It is monitored from that moment, but its reports are
only visible once someone attaches it to a Patchstack account. Opening the dashboard link that scan
and status print does that. claim does the same thing from the terminal, for when the link is
output nobody is looking at.
npx @patchstack/connect claim
Your code: BQDX-7ZKM
Claim at: https://api.patchstack.com/monitor/pulse/device?code=BQDX-7ZKM
Open that link and sign in to Patchstack — or create an account — to attach
this site to it. Whoever approves becomes the site's owner.
If you are an assistant running this, the sequence is three steps:
1. npx @patchstack/connect claim → prints the link, exits straight away
2. Give the user the link. Wait for them to say they have done it.
3. npx @patchstack/connect claim → the SAME command again, after they confirm.
- Step 1 exits immediately when the output is piped or captured, rather than blocking for ten minutes on a link you cannot see yet.
- Step 3 is the same command. While a request is still valid it resumes rather than restarting, so
running
claimagain never invalidates the link the user is looking at. If they have not finished yet it says so, with the time remaining, and exits 0. - An already-claimed site exits 0, not 1. It is the goal state. Re-running after the user claimed in the browser reports that and stops; it is not a setup failure.
claim --wait is the blocking variant. Prefer the plain re-run — it keeps each command short, which
is what fits a conversation.
- It does not rotate the credential. The project already holds one from provisioning, and CI,
deploys and other checkouts keep working. (
loginis the command that rotates; use it only to recover a lost credential.) A credential is written only when the server issues one for a checkout that had none. - It does not open a browser, and it cannot claim on the user's behalf: the approval is a person signing in to Patchstack.
- It does not work in CI — there is no browser and no one to sign in. It refuses and exits 1.
Use this when the project already has a site but its credential is gone or rejected: .patchstackrc.local.json was deleted, the repo was cloned without it (it is git-ignored, so a fresh clone never has it), a container was recycled, or ingest started failing with 401. The site UUID login needs comes from the committed .patchstackrc.json.
Do not "fix" a missing credential by deleting
.patchstackrc.jsonand runningscanagain. That file holds the site UUID, and without itscanprovisions a second site — the original, with all its history and its widget tag already live on the deployed page, is orphaned.loginrecovers the credential for the site you already have.
npx @patchstack/connect login
Your code: WDJB-MJHT
Approve at: https://api.patchstack.com/monitor/pulse/device?code=WDJB-MJHT
Waiting for approval… ✓ Credential restored
The command asks Patchstack for a short code, prints a link, and polls until the site's owner approves it in the dashboard. On approval it writes the new credential into .patchstackrc.local.json, reports whether that file is covered by .gitignore, and exits. The link opens the approval page with the code already filled in, so the person only has to confirm.
The command exits immediately when you run it. It detects that its output is being captured rather than watched by a person, prints the link, and returns. It does not block waiting for approval, because you would not see the link until it exited — by which time the code would have expired, and it would look like the command had hung.
1. npx @patchstack/connect login → prints the link, exits straight away
2. give the user the link, verbatim → they approve it in the browser
3. npx @patchstack/connect login → the SAME command again, after they confirm.
It resumes the request and finishes the flow
- Never wrap step 1 in a timeout or kill it — it returns on its own. If you find yourself waiting on it, something else is wrong.
- Step 3 is the same command. While a request is still valid it resumes rather than restarting, so running
loginagain never invalidates the link the user is looking at. If they have not approved yet it tells you so, with the time remaining, and exits. - Nothing changes until step 3 runs. Approving only marks the request; the credential is rotated and written when the CLI redeems it. So an abandoned flow is harmless — the site keeps working — but the credential is not restored until you come back.
- Surface the link verbatim. Approval requires the user's signed-in Patchstack account, which you do not have and must never ask for.
- Report the outcome. On success, say the credential was restored and that the previous one no longer works — see the warning below.
login --wait is the blocking variant: it polls until approved instead of returning. Prefer the plain re-run — it keeps each command short, which is what fits a conversation.
You cannot complete this alone. It is deliberately a human-in-the-loop step: starting the flow proves nothing about who is running it, so the only authorisation is an owner approving in the browser.
(In an interactive terminal the same command prints the link and then waits, since a person can watch it stream. You get the two-step form; a human at a shell gets the one-step form.)
Approving rotates the credential — the old one stops working immediately. Anywhere it was configured needs the new value: CI secrets, hosting-platform env vars, preview environments, other developers' checkouts. Say this before they approve, not after.
| Situation | What happens | What to do |
|---|---|---|
| Site was never claimed | 409 — no owner exists to approve |
Ask the user to claim the site in the dashboard first, or, if the site is disposable, delete .patchstackrc.json and .patchstackrc.local.json and scan to provision a fresh one — leaving the old credential behind means the next scan starts out holding one that belongs to a different site |
| Running in CI | Refuses to start | CI takes its credential from PATCHSTACK_PULSE_AUTH; login is for a developer machine |
No siteUuid configured |
Refuses to start | There is no site to recover — run scan |
| Code expired | --wait ends after 10 minutes |
Start again from step 1 for a new code |
--wait with nothing pending |
"No login is waiting for approval" | Run step 1 first; --wait resumes a request, it does not start one |
Remove only the pieces that are actually present — check for each first. If none are present, Patchstack isn't installed; report that and stop. If the user asked to remove only one piece (e.g. "just the Patchstack Connector"), remove only that piece.
- Read the site UUID from
.patchstackrc.jsonbefore deleting anything. It is the only local record of the provisioned site — report it to the user at the end so they can identify the site in their dashboard. - Remove the Patchstack Connector snippets from the layout/template: the
<script src="https://cdn.patchstack.com/patchstack-widget.js">tag and anyPatchstackWidget.init(...)call (which may live in a separate client component/plugin/effect). Afterwards, grep the repo forpatchstack-widgetandPatchstackWidgetto confirm nothing remains. - Remove runtime protection before uninstalling the package. Delete the Connect-managed guard/rules files and remove only their managed imports, middleware registrations, tunnel code, and
#region patchstack…blocks from the framework/server files. Preserve unrelated middleware and application code. Runrg "patchstack|x-ps-target"(or the available equivalent) afterwards and inspect every remaining source hit. - Remove the hooks from
package.jsonscripts. If a hook was chained (e.g."postbuild": "existing-command && patchstack-connect mark-build"), remove only thepatchstack-connect …part and keep the rest; if removal leaves a script empty, delete the key. - Signal Patchstack that the package is being removed: run
npx @patchstack/connect uninstall(while the package is still installed and.patchstackrc.jsonstill exists). If the site was never claimed, this deletes its anonymous record on Patchstack; if the site is claimed, it is only flagged — the record stays until its owner removes it in the dashboard. A failed signal must not stop the uninstall; continue with the remaining steps. - Uninstall the package with the manager matching the lockfile:
npm uninstall/pnpm remove/yarn remove/bun remove@patchstack/connect. Don't hand-editnode_modulesor the lockfile. - Delete
.patchstackrc.jsonand.patchstackrc.local.json(the second holds the API key and is git-ignored, so it is present locally even when the repo shows nothing), remove the.gitignoreentry setup added for it, and removePATCHSTACK_SITE_UUID,PATCHSTACK_API_KEY(and public-prefixed variants likeNEXT_PUBLIC_PATCHSTACK_SITE_UUID) from env files and CI variables. - Commit the changes. Reporting stops immediately. On HTML shells the
window.__PATCHSTACK_PROD__flag thatmark-buildstamped on production builds lives only in build output — the next build simply won't contain it (rebuild if build output is committed). On JSX roots and Astro layoutsscanwrote the same marker into source; remove that managed#region patchstackblock (or the hand-pasted equivalent) with the widget tag in step 2.
The uninstall signal is the only account-side effect local removal can have: it deletes an unclaimed (anonymous) record and merely flags a claimed one. A claimed site keeps using a site slot until its owner removes it in the dashboard at https://app.patchstack.com — end your report by telling the user this, alongside the site UUID from step 1. Never attempt to authenticate or remove a claimed site on the user's behalf.
The reverse also holds: removing the site on Patchstack's side (dashboard delete or the widget's uninstall flow) does not touch these local files — they must still be removed with the steps above. npx @patchstack/connect status shows Site status: removed from Patchstack in that state.
{ "scripts": { "prebuild": "patchstack-connect scan", "postbuild": "patchstack-connect mark-build", "postinstall": "patchstack-connect scan" } }