Use it inside your code host

Add one step to the CI job that runs on your pull requests. It reviews the change, prints the review in the job, posts it on the pull request where the host allows, and fails the job on the verdict you choose so the host can block the merge. You do not need a visualdiff account. An API key is optional: it keeps reports in a workspace and lets the CLI comment on hosts other than GitHub.

Want every push reviewed without touching CI, with reports kept and shared? Sign up and connect your code host instead, or compare the two on Get started.

How it works

The CLI is one JavaScript file with no dependencies, served at https://visualdiff.ai/cli/visualdiff.mjs. It needs Node 18 or newer and git, with the target branch fetched. In the job it:

  1. Finds the target branch from the CI environment and the merge base between it and HEAD.
  2. Diffs HEAD against the merge base and collects the commits.
  3. Reads the repository tree, the dependency manifests and .visualdiff.json from the merge base, not from your branch.
  4. Builds an import graph from the checkout: which files import the changed ones, and what the changed ones import.
  5. Sends all of it to the visualdiff API, which returns the review.
  6. Prints the review, writes it where the host shows it, comments if you asked, and exits with a code that passes or fails the job.

The engine is deterministic: the same change gets the same verdict. If you would rather not download the script on every run, commit it to your repository.

What reviewers see

A comment on the pull request

With --comment, the review is posted as one comment and edited in place on every run: it carries the hidden marker <!-- visualdiff-review -->, and the CLI edits the comment that has it. The comment gives the verdict and summary, the reasons, the hunks to read first with links to the exact lines, what is safe to skip, and notes on intent, provenance, dependencies, tests and infrastructure. With a stored report, it also shows the review card and a link to the full report.

  • GitHub Actions: the CLI posts the comment itself with the job’s GITHUB_TOKEN. The job needs pull-requests: write. No visualdiff key is involved.
  • GitLab, Bitbucket and Azure DevOps: the comment goes through your workspace’s connection to the host, so it needs an API key with the write scope and the host connected in Settings. Without a key, leave --comment off: the request is refused.
  • Gitea and Forgejo: for the comment and a status, add the webhook from a workspace. A packaged action is on the roadmap.

The check

The CLI does not set a separate status. The check reviewers see is the CI job itself: it passes or fails on the verdict you choose with --fail-on.

Exit codeWhen
0The review ran and the verdict is below --fail-on, or there were no changes between the base and HEAD. With --fail-on never, the default, every review exits 0.
1The verdict is at or above --fail-on. --fail-on hold fails only on hold; --fail-on review fails on review and hold.
2The review could not run: not a git repository, Node older than 18, no base branch, no merge base (history not fetched), or the server refused or could not be reached. The message names the cause and, from the server, a trace id.

The job summary and log

  • GitHub Actions shows the Markdown review on the run’s summary page; the CLI writes it to GITHUB_STEP_SUMMARY.
  • Every CI shows the text review in the job log: the verdict (“HOLD: do not merge yet”, “REVIEW: needs a careful read” or “SHIP: low risk”) with the risk out of 100, the summary, how many lines to read, skim and skip with the minutes needed, up to eight critical and high findings with their file and line, blocking policy violations, and the link to the full review when it is stored.
  • Anywhere else, --output visualdiff.md writes the Markdown review to a file. The GitLab recipe below exposes it on the merge request, and the Azure recipe adds it as a tab on the run’s summary.

Gate the merge

Pass --fail-on hold (or review), then make the job required for the target branch:

HostWhere
GitHubA branch protection rule or ruleset for the target branch: require status checks to pass, and add the job. In the recipe below the job is named review.
GitLabSettings, Merge requests: turn on the merge check that pipelines must succeed.
Bitbucket CloudBranch restrictions for the target branch: require successful builds with no failed builds. Bitbucket only prevents the merge when merge checks are enforced, which is a Premium feature; otherwise it warns.
Azure DevOpsThe target branch’s branch policies: add the pipeline under Build validation and set it to Required. For Azure Repos, this is also what runs the pipeline on pull requests.
Gitea and ForgejoBranch protection for the target branch: enable status checks and add the job’s check by name or pattern.

To make it advisory instead, leave out --fail-on, or let the job fail without failing the pipeline (continue-on-error in GitHub Actions, allow_failure in GitLab CI, continueOnError in Azure Pipelines).

Setup recipes

Each recipe works without an account. The comments in each show the one or two changes that add an API key.

GitHub Actions

.github/workflows/visualdiff.yml
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 --fail-on hold
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # Optional: keep the report in a workspace. Add the secret, then --store.
          # VISUALDIFF_KEY: ${{ secrets.VISUALDIFF_KEY }}
  • fetch-depth: 0 makes the merge base reachable. Without it the CLI stops with “no merge base”.
  • pull-requests: write and GITHUB_TOKEN let the CLI comment. GitHub Enterprise Server works the same way.
  • Pull requests from forks get a read-only token and no secrets: the review still runs and prints, and the CLI reports that it could not comment.

GitLab CI

.gitlab-ci.yml
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 --fail-on hold --output visualdiff.md
  artifacts:
    when: always
    expose_as: visualdiff review
    paths:
      - visualdiff.md
  # Optional: with VISUALDIFF_KEY as a masked CI/CD variable, add --store to keep
  # the report, and --comment to post it through your workspace's GitLab connection.
  • The job runs only in merge request pipelines. GIT_DEPTH: "0" and the fetch make the target branch available; the CLI uses GitLab’s diff base commit.
  • expose_as puts a link to the Markdown review on the merge request, and when: always keeps it when the job fails on hold.

Bitbucket Pipelines

bitbucket-pipelines.yml
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 --fail-on hold
# Optional: with VISUALDIFF_KEY as a secured repository variable, add --store to
# keep the report, and --comment to post it through your workspace's Bitbucket connection.
  • clone: depth: full brings the whole history, so the target branch and the merge base are there.
  • The review is in the step’s log. The pipeline’s environment has no pull request title or description, so the CLI uses the last commit’s subject as the title.

Azure Pipelines

azure-pipelines.yml
pr:                         # used for GitHub and Bitbucket repositories; Azure Repos
  branches:                 # uses the build validation policy described below
    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"
      status=0
      node "$(Agent.TempDirectory)/visualdiff.mjs" --fail-on hold --output "$(Agent.TempDirectory)/visualdiff.md" || status=$?
      echo "##vso[task.uploadsummary]$(Agent.TempDirectory)/visualdiff.md"
      exit $status
    displayName: visualdiff
    # Optional: with a secret variable VISUALDIFF_KEY, map it and add --store
    # (and --comment once Azure DevOps is connected in your workspace):
    # env:
    #   VISUALDIFF_KEY: $(VISUALDIFF_KEY)
  • For repositories in Azure Repos, the pr: trigger is not used. Add the pipeline as a Build validation in the target branch’s policies; that runs it on every pull request and can make it required.
  • The uploadsummary line adds the Markdown review as a tab on the run’s summary, whether the job passes or fails.
  • The CLI builds the pull request link from the collection URI, project and repository, and uses the last commit’s subject as the title.

Gitea and Forgejo Actions

.gitea/workflows/visualdiff.yml or .forgejo/workflows/visualdiff.yml
name: visualdiff
on: pull_request
jobs:
  review:
    runs-on: ubuntu-latest    # a label your runner offers
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o /tmp/visualdiff.mjs
      - run: node /tmp/visualdiff.mjs --fail-on hold
  • Actions must be enabled for the repository, with a runner registered. Change runs-on to a label your runner offers.
  • The CLI reads the target branch and the pull request from the Actions environment, as on GitHub.
  • This recipe reviews and gates. For the comment and the commit status on the pull request, add the webhook from a workspace. The packaged action is Planned.

Other CI systems

Jenkins, Buildkite, CircleCI, Drone, Woodpecker and anything else that runs a script work the same way: check out with full history, fetch the target branch, and pass it with --base. The same works on your own machine.

shell steps for any CI
git fetch origin main
curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o /tmp/visualdiff.mjs
node /tmp/visualdiff.mjs --base origin/main --fail-on hold --output visualdiff.md

CLI options

Run node visualdiff.mjs --help for the same list.

OptionWhat it does
--base <ref>The target branch or commit. Default: from the CI environment, else origin/main.
--fail-on <verdict>hold, review or never. The job exits 1 when the verdict is at or above it. Default: never.
--commentPost the review on the pull request. On GitHub Actions, with $GITHUB_TOKEN. Elsewhere, through your workspace’s connection for the host, which needs a key.
--storeKeep the report in your workspace and print its link. Needs a key with the write scope.
--key <vdk_…>An API key. Default: $VISUALDIFF_KEY. Prefer the variable, so the key stays out of logs.
--server <url>The visualdiff server. Default: $VISUALDIFF_URL, else https://visualdiff.ai.
--format <format>What to print: text, markdown or json. Default: text.
--output <file>Also write the Markdown review to a file, for an artifact or a summary tab.
--budget <minutes>The review time budget. Default: the workspace setting with a key, else 20.
--no-narrateSkip the model-written summary.
--dry-runPrint what would be sent, and send nothing.
--help, --versionPrint the options, or the CLI version.

What it reads from the CI environment

CITarget branchPull request details
GitHub ActionsGITHUB_BASE_REFThe pull request from GITHUB_EVENT_PATH: number, title, description, author, branches and head commit. The Markdown review is added to GITHUB_STEP_SUMMARY.
GitLab CICI_MERGE_REQUEST_DIFF_BASE_SHA, else CI_MERGE_REQUEST_TARGET_BRANCH_NAMECI_MERGE_REQUEST_IID, CI_PROJECT_PATH, CI_MERGE_REQUEST_TITLE and CI_MERGE_REQUEST_DESCRIPTION.
Bitbucket PipelinesBITBUCKET_PR_DESTINATION_BRANCHBITBUCKET_PR_ID and BITBUCKET_REPO_FULL_NAME. The title is the last commit’s subject.
Azure PipelinesSYSTEM_PULLREQUEST_TARGETBRANCHSYSTEM_PULLREQUEST_PULLREQUESTID, SYSTEM_COLLECTIONURI, SYSTEM_TEAMPROJECT and BUILD_REPOSITORY_NAME. The title is the last commit’s subject.
Gitea and Forgejo ActionsGITHUB_BASE_REF, GITEA_BASE_REF or FORGEJO_BASE_REFThe pull request from the Actions event file, as on GitHub.
Anything elseorigin/main, origin/master, main or master, or --baseThe last commit’s subject as the title, and the repository name from the origin remote.

Three environment variables are read directly: VISUALDIFF_KEY (the key), VISUALDIFF_URL (the server) and, on GitHub Actions, GITHUB_TOKEN (for the comment).

Guest mode or an API key

Without a key, the CLI runs in guest mode: the review is computed, returned and printed, and nothing is kept at visualdiff. A key ties the run to a workspace. To get one, sign in, create a key with the write scope in Settings, API keys, and store it as a CI secret named VISUALDIFF_KEY.

Guest, no keyWith a key (write scope)
Review in the log and job summary, exit codesYesYes
Keep the report with --storeNo: the request is refused, so leave --store offYes, in the workspace inbox, with a share link printed in the job
Comment from GitHub ActionsYes, with GITHUB_TOKENYes, with the review card and report link when stored
Comment on GitLab, Bitbucket and Azure DevOpsNo: the request is refused, so leave --comment offYes, through the host connected in the workspace
SummaryWritten by rules from the engine’s factsWritten by the model when the deployment has one, from facts only (--no-narrate skips it)
ArchCanvas infrastructure matchingNoYes, when ArchCanvas is connected in the workspace
Policy when the repository has no .visualdiff.jsonThe defaultsThe workspace policy
Budget without --budget20 minutesThe workspace’s default budget
Sanity indexNoYes, from stored reports
Rate limit8 analyses a minute per network address30 requests per 10 seconds per key

Policy from the merge base

Commit .visualdiff.json to the default branch. The CLI reads it with git show <merge-base>:.visualdiff.json, so a pull request is judged by the policy on the target branch, not by its own copy. A pull request that changes the file is judged by the old rules; the new ones apply once it is merged.

  • Without the file, the workspace policy applies when you use a key, and the defaults otherwise.
  • If the file is not valid JSON, the CLI says so and the workspace policy or the defaults apply.
  • Keys you leave out keep their defaults. A key you set replaces its default entirely, so a protected list replaces the default list: copy across any default entries you want to keep. The policy reference lists the keys and defaults.

The policy is safe from the pull request, but the pipeline file is not: CI runs the pipeline definition from the pull request, so a change could edit the job and drop --fail-on. Block changes to pipeline files in the policy, and require review of them from their owners where your host supports it. Webhook reviews from a workspace do not run from the pull request.

.visualdiff.json
{
  "maxChurnAI": 400,
  "blockOn": ["critical", "high"],
  "protected": [
    { "path": "**/auth/**", "reason": "Security reviews auth changes.", "level": "block" },
    { "path": ".github/workflows/**", "reason": "The review step lives here.", "level": "block" },
    { "path": ".gitlab-ci.yml", "level": "block" },
    { "path": "bitbucket-pipelines.yml", "level": "block" },
    { "path": "azure-pipelines.yml", "level": "block" }
  ],
  "requireHumanCommitForAI": true
}

What is sent

The CLI sends the change and the context needed to judge it to the server, by default https://visualdiff.ai:

  • The diff between the merge base and HEAD.
  • Up to 300 commits: message, author, committer and date.
  • The paths and sizes of files in the base tree, up to 60,000.
  • Dependency manifests at the base: package.json, requirements.txt, pyproject.toml, go.mod, Gemfile, Cargo.toml and composer.json.
  • .visualdiff.json from the base, if there is one.
  • Import edges that touch a changed file, from up to 6,000 source files outside node_modules, dist, build, vendor and .next.
  • The full text of up to 60 changed source files under 400 KB each.
  • The pull request’s number, title, description, author and branches, where the CI provides them.

Run with --dry-run to see a summary of what would be sent, without sending it. In guest mode the analysis runs in memory and nothing is stored. The model, where one is configured, receives counts, paths and finding titles, never code. To keep everything inside your network, self-host visualdiff and point the CLI at it with --server or VISUALDIFF_URL.

Marketplace apps

Today every host works through the CLI in CI, and the five supported hosts also through a token and a webhook from a workspace. Native apps, installed from each host’s marketplace, will remove the tokens and add surfaces inside the host. Statuses below match the roadmap; tell us which one you need first at [email protected].

  • GitHubGitHub App on GitHub MarketplaceNext

    GitHub App

    • Installed once per organisation; installation tokens scoped per repository, so no personal tokens.
    • Check runs with annotations on the exact lines in the plan’s read lane, and a Re-run button.
    • The comment and the status come from the app, not from a person’s account.

    Today Available Token + webhook, or the CLI in GitHub Actions. Comments, commit statuses, GitHub Enterprise Server.

  • GitLabGitLab integration (partner listing)Planned

    OAuth application and group webhook

    • Installed at group level, with no project tokens.
    • A merge request widget through external status checks.

    Today Available Project or group token + webhook, or the CLI in GitLab CI. GitLab.com and self-managed.

  • Bitbucket CloudAtlassian Marketplace app (Forge)Planned

    Forge app

    • A pull request panel with the plan inside Bitbucket.
    • Code Insights reports with annotations.
    • Jira issues linked to held reviews.

    Today Available Access token + webhook, or the CLI in Bitbucket Pipelines. Build statuses and comments.

  • Azure DevOpsVisual Studio Marketplace extensionPlanned

    Extension

    • A pull request tab with the plan and the change map.
    • A branch-policy status check.
    • Service hooks created by the extension, so there is nothing to paste.

    Today Available PAT + service hook, or the CLI in Azure Pipelines. Pull request statuses and threads.

  • Gitea and ForgejoGitea / Forgejo actionPlanned

    Packaged action

    • One uses: line for Gitea and Forgejo runners, wrapping the CLI.

    Today Available Token + webhook, self-hosted or Codeberg. Comments and commit statuses.

  • Bitbucket Data CenterAtlassian Marketplace app (Data Center)Exploring

    Server plugin, starting with a Data Center REST adapter

    • For teams that cannot reach a hosted service.

    Today Next The CLI works in any pipeline today; native pull request comments need the Data Center API adapter.

  • GerritGerrit pluginExploring

    Plugin and REST adapter

    • Votes Code-Review −1 on held changes, with the plan as a comment.

    Today Next The CLI can review any patchset in CI; a Gerrit adapter will post review comments and votes.

  • AWS CodeCatalyst and othersNo marketplace app planned yetExploring

    Driven by demand.

    Today Available Any host that can run a script: the CLI posts the diff and prints the review as Markdown.

Also on the way

  • MCP server Next Agents call visualdiff as a tool on their working tree before they open a pull request.
  • VS Code and JetBrains Planned The review plan beside the diff in the editor, with jump-to-line.
  • Jira and Linear Planned Check the change against the ticket it claims to close, not only its description.

Questions

Do I need a visualdiff account?

No. The recipes run in guest mode. An account is only needed for an API key, which adds stored reports, comments outside GitHub Actions, ArchCanvas and the sanity index.

Does my code leave the runner?

Yes: the diff and the context listed under What is sent go to the server you point the CLI at. Use --dry-run to check, or self-host the server.

Why did the job exit with code 2?

Read the line that starts with visualdiff:. The usual causes are a shallow clone (“no merge base”: fetch the full history), --store or --comment outside GitHub Actions without a key (the server refuses them), or a runner that cannot reach the server.

The comment did not appear on GitHub

Check that the job has pull-requests: write, that GITHUB_TOKEN is in the step’s environment, and that the pull request is not from a fork. The log says “could not comment” with the HTTP status when GitHub refuses.

Why is the title the last commit message?

Bitbucket Pipelines and Azure Pipelines do not pass the pull request’s title and description to the job, so the intent check has less to compare against. A webhook review from a workspace reads the real description.

Can I keep the CLI at a fixed version?

Commit visualdiff.mjs to your repository and run it from there. --version prints its version.

Can I run it before opening a pull request?

Yes. From any checkout with git and Node 18, run node visualdiff.mjs --base origin/main. Agents can run it the same way and read --format json; an MCP server for this is Next.

How large a change can it review?

The request is limited to 16 MB, which covers the diff and every file sent with it. Larger repositories are sampled: the first 60,000 tree entries and 6,000 source files for imports.

Read next