Quick start
Paste a pull request link into the inbox. Public repositories need nothing else. For a private repository, add a token for its host in Settings, or paste the output of git diff origin/main...HEAD instead of a link.
The report opens on the review plan: the hunks to read closely, to skim and to skip, in reading order, inside the time budget at the top. The other tabs answer one question each: where the change sits (map), what looks wrong (findings), whether it does what it says (intent), who wrote it (provenance), what it tests and imports, and which running resources it reaches.
CI recipes
The CLI is one file with no dependencies. It diffs HEAD against the merge base with the target branch, reads the base tree, manifests and .visualdiff.json from the base revision, builds an import graph from the checkout, and posts it all to the API. It prints the review, writes it to the GitHub job summary, and exits 1 on the verdict you pass to --fail-on. Run node visualdiff.mjs --help for every option, or --dry-run to see what would be sent.
Create a key with the write scope in Settings and store it as VISUALDIFF_KEY. Without a key the review still runs; it is printed, not stored. If you would rather not download the script on every run, commit it to your repository.
GitHub Actions
name: visualdiff
on: pull_request
permissions:
contents: read
pull-requests: write # to post the review comment
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the merge base must be reachable
- uses: actions/setup-node@v4
with:
node-version: 22
- run: curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o "$RUNNER_TEMP/visualdiff.mjs"
- run: node "$RUNNER_TEMP/visualdiff.mjs" --comment --store --fail-on hold
env:
VISUALDIFF_KEY: ${{ secrets.VISUALDIFF_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}GitLab CI
visualdiff:
image: node:22
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
variables:
GIT_DEPTH: "0"
script:
- git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
- curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o /tmp/visualdiff.mjs
- node /tmp/visualdiff.mjs --store --comment --fail-on hold
# VISUALDIFF_KEY: a masked CI/CD variable. --comment posts through your
# workspace's GitLab connection (Settings → Code hosts).Bitbucket Pipelines
pipelines:
pull-requests:
'**':
- step:
name: visualdiff
image: node:22
clone:
depth: full
script:
- git fetch origin "$BITBUCKET_PR_DESTINATION_BRANCH"
- curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o /tmp/visualdiff.mjs
- node /tmp/visualdiff.mjs --store --comment --fail-on hold
# VISUALDIFF_KEY: a secured repository variable.Azure Pipelines
pr:
branches:
include: [main]
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
fetchDepth: 0
- task: NodeTool@0
inputs:
versionSpec: '22.x'
- script: |
curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o "$(Agent.TempDirectory)/visualdiff.mjs"
node "$(Agent.TempDirectory)/visualdiff.mjs" --store --comment --fail-on hold
env:
VISUALDIFF_KEY: $(VISUALDIFF_KEY)Jenkins, Buildkite, CircleCI, Drone and Woodpecker work the same way: check out with full history, then run the CLI with --base origin/<target>.
Webhooks
With a webhook, every push to a pull request is reviewed without CI: visualdiff fetches the change, stores the report, edits its one comment in place and, if you turn it on, sets a status that fails on hold. Add the host in Settings → Code hosts with a token; the page shows a webhook URL and secret once.
| Host | Where | Events and secret |
|---|---|---|
| GitHub | Repository or organisation → Settings → Webhooks | Content type application/json, the secret, event “Pull requests”. |
| GitLab | Project or group → Settings → Webhooks | Secret token, trigger “Merge request events”. |
| Bitbucket Cloud | Repository settings → Webhooks | Secret, triggers “Pull request: Created” and “Updated”. |
| Azure DevOps | Project settings → Service hooks → Web Hooks | “Pull request created” and “Pull request updated”; basic authentication with any username and the secret as password. |
| Gitea / Forgejo | Repository → Settings → Webhooks → Gitea | POST, application/json, the secret, event “Pull Request”. |
Tokens
- GitHub: Fine-grained token: Pull requests (read & write to comment), Contents (read), Commit statuses (read & write for status checks).
- GitLab: Project or group access token with read_api (api to post notes and statuses), role Reporter or above.
- Bitbucket Cloud: Repository access token with Pull requests: Write and Repositories: Read; or an Atlassian API token with your email as the username.
- Azure DevOps: Personal access token with Code (Read), plus Code (Status) and Pull Request Threads (Read & write) to report back.
- Gitea / Forgejo: Access token with read:repository (write:issue to comment, write:repository for statuses).
Policy
Commit .visualdiff.json to the default branch. It is read from the base revision of each pull request, so a change cannot loosen the rules it is judged by. Without one, the workspace policy from Settings applies, and without that the defaults. Keys you leave out keep their defaults. A blocking violation turns the verdict to hold; a warning adds a reason.
{
"maxChurn": 1200,
"maxChurnAI": 400,
"maxFiles": 60,
"requireTestsFor": ["src/**", "app/**"],
"protected": [
{ "path": "**/auth/**", "reason": "Security reviews auth changes.", "level": "block" },
{ "path": "infra/**", "reason": "Platform owns Terraform.", "level": "warn" },
{ "path": ".github/workflows/**", "level": "block", "aiOnly": true }
],
"blockOn": ["critical", "high"],
"forbidNewDependencies": false,
"requireHumanCommitForAI": true,
"blockDriftForAI": true,
"allowedHosts": ["*.acme.com", "api.stripe.com"],
"blockUnknownHosts": true,
"approvedVendors": ["stripe", "sentry"],
"blockUnapprovedVendors": false,
"ignore": ["**/__snapshots__/**", "docs/generated/**"]
}| Key | Meaning |
|---|---|
maxChurn, maxChurnAI | Most reviewable changed lines; the second applies to agent-written changes. |
maxFiles | Most files one change may touch. |
requireTestsFor | Globs whose source changes need a test change. |
protected | Paths that warn or block when touched; aiOnly limits it to agent-written changes. |
blockOn | Finding severities that hold the change. |
allowedHosts, blockUnknownHosts | Outbound hosts the code may add (exact names or *. suffixes). With blockUnknownHosts, a new destination that is unknown, a raw IP or a data drop, and not on the list, holds the change. See poisoned code. |
approvedVendors, blockUnapprovedVendors | Outside vendors by id (stripe, sentry, segment…). A new vendor not on the list is a warning, or a hold with blockUnapprovedVendors. |
forbidNewDependencies | Any new dependency holds the change. |
requireHumanCommitForAI | Agent-written changes need a person’s commit on top. |
blockDriftForAI | Intent drift holds agent-written changes. |
ignore | Paths left out of the analysis entirely. |
ArchCanvas
ArchCanvas maps your cloud accounts into one resource graph. Connected, every review matches the change to it: Terraform resources by address and name, Kubernetes objects, env vars like ORDERS_DB_URL, hostnames the code calls, and the services that deploy this repository. The Infrastructure tab lists the matched resources, their neighbours one and two hops away, and opens the live map with the change painted on it.
- In ArchCanvas, create an API key with the read scope (it starts with
ack_). - In visualdiff, Settings → ArchCanvas: paste the key and pick a document, or keep
mergedfor the newest snapshot of every source. - Open any report’s Infrastructure tab.
visualdiff only reads from ArchCanvas. A self-hosted ArchCanvas works too: set its address in the same form.
Notifications
Add a Slack incoming webhook or any HTTPS endpoint in Settings. Reviews from webhooks call it on hold (or on every review). Generic webhooks receive a JSON event signed with X-Visualdiff-Signature: sha256=<hmac> using the signing secret shown once when you add it.
API
Send a key as Authorization: Bearer vdk_…. Keys carry the scopes they were minted with: read for reports, write to analyse and store, admin for connections, keys and settings. Requests are limited to 30 per 10 seconds per key. Errors are { error, message } with a trace to quote.
curl -s https://visualdiff.ai/api/v1/analyze \
-H "Authorization: Bearer $VISUALDIFF_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://github.com/acme/api/pull/482", "store": true}' \
| jq '.report.verdict, .url'
git diff origin/main...HEAD | jq -Rs '{diff: .}' \
| curl -s https://visualdiff.ai/api/v1/analyze -H "Content-Type: application/json" -d @- \
| jq '.report.plan.totals'| Method | Path | Scope | What it does |
|---|---|---|---|
POST | /api/v1/analyze | none / write | Analyse {diff} or {url}. Signed out: returned, not stored. store, comment, status need write. format=json|markdown|svg. |
GET | /api/v1/reports | read | Stored reports, newest first; ?repo=, ?before=, ?limit= |
GET | /api/v1/reports/:id | read or ?t= | One report; format=markdown|svg; the share token opens it without a key. |
DELETE | /api/v1/reports/:id | write | Delete a stored report. |
GET | /api/v1/reports/:id/card?t= | share token | The review card as SVG (for comments and chat). |
GET | /api/v1/sanity | read | Weekly sanity index per workspace or ?repo=. |
GET | /api/v1/settings | read | Review settings and the default policy. |
PATCH | /api/v1/settings | admin | budgetMinutes, commentOnPullRequests, statusChecks, failOnHold, policy. |
GET | /api/v1/connections | read | Code hosts, ArchCanvas, Slack and webhooks (never secrets). |
POST | /api/v1/connections | admin | Add or replace a connection; returns the webhook URL and secret once. |
POST | /api/v1/connections/:id/verify | admin | Try the stored credential. |
GET | /api/v1/archcanvas/documents | read | Maps the workspace’s ArchCanvas key can read. |
GET | /api/v1/keys | admin | API keys (prefix, scopes, last used). |
POST | /api/v1/webhooks/:host/:connection | signature | Pull request events from a code host. |
GET | /api/v1/health | none | Liveness, database and which features are on. |
Self-hosting on Railway
visualdiff is one Next.js service and one Postgres database. Create a Railway project from the repository (it builds from the Dockerfile and checks /api/health), add the Postgres plugin, and set these variables. The schema creates itself on first use.
| Variable | Purpose |
|---|---|
DATABASE_URL | Postgres (Railway plugin). Without it: guest mode, nothing stored, no sign-in. |
APP_BASE_URL | Public URL, e.g. https://visualdiff.ai. Used for links in comments and OIDC redirects. |
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET | Sign-in (Authorization Code + PKCE). OIDC_REDIRECT_URI defaults to APP_BASE_URL/callback. |
CREDENTIALS_KEY | 32 random bytes, base64: encrypts host tokens and keys at rest (AES-256-GCM). Required to save connections. |
ANTHROPIC_API_KEY | Optional: Claude writes the summary from computed facts. NARRATIVE=off disables it; NARRATIVE_MODEL overrides the model. |
ARCHCANVAS_URL, ARCHCANVAS_API_KEY, ARCHCANVAS_DOCUMENT | Optional single-tenant ArchCanvas map (otherwise per workspace in Settings). |
GITHUB_PUBLIC_TOKEN | Optional: raises GitHub’s rate limit for public links. Use a token with access to no private repositories. |
REPORTS_KEPT | Reports kept per workspace (default 2000). |
Security
- visualdiff can read pull requests and write one comment and a status. It never pushes, merges or changes settings.
- Host tokens, API keys for ArchCanvas and webhook secrets are encrypted with AES-256-GCM. API keys and sessions are stored only as SHA-256 hashes.
- Addresses you enter (self-managed hosts, ArchCanvas) are fetched over HTTPS only, and private or local network addresses are refused at every redirect.
- Webhooks are verified by HMAC signature or shared secret before anything is read.
- The optional model summary receives counts, paths and finding titles, never code or finding excerpts.
- Logs are one structured line per event; anything that looks like a secret is reduced to its length and last four characters.