Setup in five steps
Sign in
Your first sign-in creates a personal workspace with you as its owner.
Connect a code host
In Settings, under Code hosts, pick the host, paste a token with the scopes below and press Connect.
Verify the connection
Press Test. visualdiff makes one read with the token and tells you who it signed in as.
Add the webhook
Copy the payload URL and the secret, which are shown once after saving, into the repository or organisation.
Push to a pull request
The review comment appears on the pull request, and the report in your inbox.
Sign in
Sign in with the button below or the one at the top of the page. visualdiff uses OpenID Connect with the Authorization Code flow and PKCE. A self-hosted deployment signs in through whichever standards-compliant provider its operator configured, such as Auth0, Okta, Keycloak or Google.
- The code is exchanged for tokens on the server; the client secret never reaches your browser. visualdiff checks the state, the nonce, and the ID token’s issuer, audience and expiry before it opens a session.
- A session lasts 30 days. The cookie is HTTP-only and holds a random token; the database stores only its SHA-256 hash.
- Your first sign-in creates a personal workspace named after you, such as “Ada’s workspace”, and makes you its owner. Later sign-ins refresh your name and email from the provider.
- Sign out from Settings, Account.
Workspaces and roles
Everything you connect and every report you store belongs to a workspace, and every database query is filtered by it. A person’s role in the workspace decides what they can do. API keys carry scopes instead of a role; see API keys.
| Role | Scopes | What they can do |
|---|---|---|
| Owner | read, write, admin | Everything: connect code hosts, ArchCanvas and notifications; change review settings and the workspace policy; create and revoke API keys; review and store pull requests; read every report. |
| Admin | read, write, admin | The same as an owner. |
| Member | read, write | Review pull requests from the inbox and store the reports; read reports, the sanity index, settings and the list of connections. Settings are read-only. |
| Viewer | read | Read stored reports, the sanity index, settings and the list of connections. Cannot store reviews or change anything. |
The app has no screen for inviting people to a workspace yet, so today each person who signs in works in their own personal workspace. To give a pipeline or a script access, create an API key.
Connect a code host
Open Settings, Code hosts as an owner or admin. Pick the host, fill in the fields and press Connect. The token is stored encrypted and used for three things: to read pull requests on private repositories, to post the review comment and to set the commit status.
| Field | When | What to enter |
|---|---|---|
| Server address | GitHub Enterprise Server, self-managed GitLab, Gitea and Forgejo | The https:// address of your server, such as https://github.acme.com. Leave it empty for github.com and gitlab.com. For Gitea and Forgejo, empty means codeberg.org. The server must be reachable from the internet over HTTPS: private and local network addresses are refused. |
| Organisation | Azure DevOps (required) | The <organisation> in dev.azure.com/<organisation>. |
| Limit to | Optional | The owner, group or repository the token is for, such as acme or acme/api. A pull request uses the oldest connection for its server whose limit covers the repository, so you can add one connection per organisation. |
| Username or email | Bitbucket Cloud | Only for an Atlassian API token (your Atlassian email) or an app password (your username). Leave it empty for a repository access token. |
| Token | Always | See your host below. |
| Name | Optional | Defaults to the limit or the server. Saving again under the same name replaces the connection: the webhook URL stays, the secret changes. |
GitHub
- Where: create a fine-grained personal access token at github.com/settings/personal-access-tokens/new (Settings, Developer settings, Personal access tokens, Fine-grained tokens). On GitHub Enterprise Server, use the same page on your server.
- Resource owner and repositories: choose the organisation that owns the repositories and only the repositories you want reviewed. Some organisations approve fine-grained tokens before they work.
- Permissions: Fine-grained token: Pull requests (read & write to comment), Contents (read), Commit statuses (read & write for status checks).
- Server address: only for GitHub Enterprise Server. visualdiff calls its API under
/api/v3.
GitLab
- Where: the project’s Settings, Access tokens. A group access token, from the group’s Settings, Access tokens, covers every project in the group.
- Scopes and role: Project or group access token with read_api (api to post notes and statuses), role Reporter or above.
- Server address: your self-managed GitLab, such as
https://gitlab.acme.com. Leave it empty for gitlab.com.
Bitbucket Cloud
- Where: Repository settings, Security, Access tokens, for a repository access token. Or an Atlassian API token from your Atlassian account’s security settings, with your email in Username or email.
- Permissions: Repository access token with Pull requests: Write and Repositories: Read; or an Atlassian API token with your email as the username.
- Limit to: a workspace or
workspace/repository. With a limit, Test reads that workspace or repository; without one, it reads the account behind the token. - Bitbucket Data Center cannot be connected yet. Its adapter is Next; until then, run the CLI in your pipeline.
Azure DevOps
- Where: User settings, Personal access tokens, New token, in the organisation that holds the repositories.
- Scopes: Personal access token with Code (Read), plus Code (Status) and Pull Request Threads (Read & write) to report back.
- Organisation: required. Pull request links on
dev.azure.com/<organisation>and<organisation>.visualstudio.comare both recognised. Azure DevOps Server, the on-premises edition, is not supported.
Gitea and Forgejo
- Where: your user Settings, Applications, Generate new token. Codeberg works the same way.
- Scopes: Access token with read:repository (write:issue to comment, write:repository for statuses).
- Server address: your instance, such as
https://git.acme.com. Leave it empty for codeberg.org.
Verify the connection
Press Test next to the connection. visualdiff makes one cheap read with the stored token:
| Host | What Test reads |
|---|---|
| GitHub | The token’s user: /user on api.github.com, or /api/v3/user on your server |
| GitLab | /api/v4/user |
| Bitbucket Cloud | The workspace or repository in Limit to, otherwise /2.0/user |
| Azure DevOps | The organisation’s connection data |
| Gitea and Forgejo | /api/v1/user |
The answer appears at the bottom of the page:
- Signed in as … The token works.
- The token was refused (HTTP 401) or (HTTP 403). The token is wrong, expired or missing a scope.
- The host answered HTTP … The server is reachable but returned an error, for example because the server address is wrong.
- Add the Azure DevOps organisation name. The Organisation field is empty.
- No token stored: public repositories only. Fetching public pull requests still works; commenting and statuses do not.
Under each connection, Settings shows when it was last used or its last error, updated by Test and by reviews you start from the inbox or the API. Press Test again whenever you replace a token.
Add the webhook
When you save a connection with a token, Settings shows a payload URL and a secret once. Copy both before you press Done: the secret is not shown again. The URL has this form, where {host} is github, gitlab, bitbucket, azure or gitea:
https://visualdiff.ai/api/webhooks/{host}/{connectionId}The connection list keeps showing the URL. To get a new secret, save the connection again with its token. The old secret stops working at once, so update the webhook in the host as well.
| Host | Where to add it | What to set | How visualdiff checks it |
|---|---|---|---|
| GitHub | Repository or organisation: Settings, Webhooks, Add webhook | Content type application/json; the secret; “Let me select individual events”, then Pull requests | X-Hub-Signature-256: an HMAC-SHA256 of the body with the secret |
| GitLab | Project or group: Settings, Webhooks | The secret as the secret token; trigger Merge request events | X-Gitlab-Token must equal the secret |
| Bitbucket Cloud | Repository settings, Webhooks, Add webhook | The secret; triggers Pull request: Created and Updated | X-Hub-Signature: an HMAC-SHA256 of the body with the secret |
| Azure DevOps | Project settings, Service hooks, Web Hooks. One subscription for Pull request created and one for Pull request updated | Basic authentication: any username, the secret as the password | The password in the Authorization header must equal the secret |
| Gitea and Forgejo | Repository: Settings, Webhooks, Add webhook, Gitea (or Forgejo) | Method POST; content type application/json; the secret; custom events, Pull request | X-Gitea-Signature or X-Forgejo-Signature (X-Hub-Signature-256 also accepted): an HMAC-SHA256 of the body |
Which events start a review
- GitHub: pull request opened, synchronize (new commits), reopened and ready for review. Ping events are answered.
- GitLab: merge request opened, reopened, and updated with new commits. Updates that change only the title, labels or other details are ignored.
- Bitbucket Cloud: pull request created and updated. The diagnostics ping is answered.
- Azure DevOps: pull request created, and pull request updated when the source branch was pushed. Other updates, such as votes or reviewer changes, are ignored.
- Gitea and Forgejo: pull request opened, synchronized and reopened.
What the host sees in its delivery log
| Answer | Meaning |
|---|---|
202 with accepted | The review started. The answer carries a trace id to quote if you contact us. |
202 with ignored | Nothing to review, with the reason: another event or action, a metadata-only update, this commit was already reviewed, or a pull request URL visualdiff does not recognise. |
200 with pong | A ping, answered. |
401 | The signature or secret did not match. The same answer is given for a connection that does not exist, so the URL cannot be probed. |
404 | The host in the URL is not one of the five, or the connection id is malformed. |
400, 413 | The body is not JSON, or larger than 5 MB. |
What happens on every push
- The code host sends the event to the payload URL.
- visualdiff finds the connection named in the URL, checks that its host matches and verifies the signature or secret before reading anything else.
- Events that are not a new or updated pull request are answered
202with the reason they were ignored. - A push already reviewed in the last ten minutes, for the same pull request and the same head commit, is answered
202and skipped. Hosts that redeliver do not cause a second review. - visualdiff answers
202straight away and reviews in the background, so the host never times out waiting. - It fetches the change with the connection’s token: the diff, the commits, the repository tree and dependency manifests at the base,
.visualdiff.jsonfrom the base revision, and the contents of up to 40 changed files. - It analyses the change under your policy, matches it to your ArchCanvas map if one is connected, checks new dependencies against npm and PyPI, and writes the summary.
- It stores the report in your workspace, with a share link.
- With a token on the connection, it posts or edits its one comment, and sets the commit status if you turned statuses on.
- It notifies Slack or your endpoint if the review is held, or on every review if you chose that.
If a review fails, the failure is logged with the trace id, and the next delivery of the same commit is reviewed again rather than skipped.
The comment and the status on each host
| Host | Comment | Status |
|---|---|---|
| GitHub | A pull request comment | Commit status with context visualdiff |
| GitLab | A merge request note | Commit status named visualdiff |
| Bitbucket Cloud | A pull request comment | Build status with key visualdiff, shown as “visualdiff review” |
| Azure DevOps | A thread, posted as closed so it never blocks a “resolve all comments” policy | Pull request status visualdiff/review |
| Gitea and Forgejo | A pull request comment | Commit status with context visualdiff |
The comment starts with a hidden marker, <!-- visualdiff-review -->. visualdiff looks for it and edits that comment, so a pull request has one review comment however often it is pushed. The comment gives the verdict and summary, the review card linking to the full report, the reasons for the verdict, the hunks to read first with links to the exact lines (on GitHub, GitLab, Bitbucket, Gitea and Forgejo), what is safe to skip, and notes on intent, provenance, dependencies, tests and infrastructure.
The status gives the verdict, the risk out of 100 and the minutes the plan needs, and links to the report. It succeeds unless the verdict is hold and Fail the status on hold is on. To block merging on hold, turn on both status settings under Review settings and make the status required in your host, for example as a required status check named visualdiff in GitHub branch protection, or a status policy for visualdiff/review in an Azure DevOps branch policy.
Inbox, reports and share links
The inbox lists your workspace’s stored reviews, newest first, with the verdict, title, repository and number, whether an agent wrote it, intent drift, risk and age. Reviews from webhooks, from the inbox and from CI with --store all land here.
- Review by link: paste a pull request link. visualdiff uses your connected token for that host automatically. For a host you have not connected, choose “Use a token once”: the token fetches that pull request and is never stored.
- Review a diff: paste the output of
git diff, with a title and what the change is meant to do, which the intent check compares against. - Budget: set the minutes a reviewer has; the plan fits the read lane into it.
A report opens on the review plan. Its tabs are Review plan, Change map, Findings, Intent, Tests & deps, Provenance, Infrastructure and Files. At the top, Copy PR comment copies the same Markdown the comment uses, Review card downloads the card image, and Share copies the share link.
- Share links end in
?t=…. Anyone with the link can open that one report without signing in. The live ArchCanvas map inside it shows only to members of your workspace. - The card is also an image at
/api/reports/{id}/card?t=…, for chat and comments. - Each workspace keeps its newest 2,000 reports. Delete one through the API with
DELETE /api/v1/reports/{id}(write scope).
Sanity index
The sanity index answers one question each week: is review keeping up with the code being written? It is built from every stored review, for one repository or all of them, over 8, 12, 26 or 52 weeks. 100 is calm.
sanity = 100
− 0.45 × average risk
− 25 × share of reviews held
− 20 × share of reviews with intent drift
− up to 10 for critical and high findings per reviewBeside it you see the number of reviews, the share written by agents, the signal (the share of changed lines worth reading) and the average risk, each as a weekly line. The same series is at GET /api/v1/sanity?repo=&weeks= with the read scope, for 4 to 52 weeks.
ArchCanvas
ArchCanvas maps your cloud accounts into one resource graph. With it connected, every review matches the change to the running resources it names: Terraform resources, Kubernetes objects, environment variables and hostnames. The Infrastructure tab lists them with their neighbours one and two hops away.
- In ArchCanvas, create an API key with the read scope. It starts with
ack_. - In Settings, ArchCanvas, keep the address (
https://archcanvas.cloud, or your own ArchCanvas), paste the key and press Connect ArchCanvas. The map starts asmerged: the newest snapshot of every source. - To use another map, pick it from the list that appears once connected: the newest document, or one document by name. Then press Save ArchCanvas.
- Press Test. It reports how many documents the key can read.
visualdiff only reads from ArchCanvas. See the docs for how matching works.
Notifications
In Settings, Notifications, add a Slack incoming webhook (its URL starts with https://hooks.slack.com/) or any HTTPS endpoint of your own, and choose Held reviews or Every review. Notifications are sent for reviews that came from a code host webhook, not for inbox or CI runs.
- Slack receives the verdict, the risk, the repository and number, the title, the summary and a link to open the review plan.
- Your endpoint receives a JSON event signed with
X-Visualdiff-Signature: sha256=<hmac>. The signing secret is shown once when you add it.
{
"event": "review.completed",
"verdict": "hold",
"risk": 72,
"pr": { "host": "github", "repo": "acme/api", "number": 482, "title": "…", "url": "…" },
"summary": "…",
"url": "https://visualdiff.ai/r/…?t=…",
"findings": 6
}import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received, before JSON.parse.
function fromVisualdiff(rawBody, header, signingSecret) {
const expected = 'sha256=' + createHmac('sha256', signingSecret).update(rawBody).digest('hex');
const a = Buffer.from(header || '');
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Review settings and policy
Owners and admins change these in Settings, Review; everyone else sees them read-only.
| Setting | Default | What it does |
|---|---|---|
| Default review budget | 20 minutes | The time budget for reviews that do not set their own, such as webhook reviews and CI runs without --budget. The slider covers 5 to 90 minutes; the API accepts 2 to 240. |
| Comment on pull requests reviewed by webhook | On | Post and edit the one review comment. |
| Set a commit status | Off | Set the visualdiff status on the pull request’s head commit (on Azure DevOps, on the pull request). |
| Fail the status on hold | Off | Make the status fail when the verdict is hold, so branch protection can block the merge. Needs the status on. |
Workspace policy
Under Settings, Policy you edit the workspace policy as JSON. visualdiff picks the first policy it finds, in this order:
.visualdiff.jsonfrom the base revision of the pull request. Because it is read from the base, a pull request cannot loosen the rules it is judged by.- The workspace policy.
- The defaults below.
Keys you leave out keep their defaults; a key you set replaces the default entirely, so a protected list replaces the default list. Reset to defaults removes the workspace policy. The policy reference explains each key.
{
"version": 1,
"maxChurn": 1200,
"maxChurnAI": 600,
"maxFiles": 60,
"requireTestsFor": [
"src/**",
"lib/**",
"app/**",
"pkg/**",
"internal/**"
],
"protected": [
{
"path": "**/auth/**",
"reason": "Authentication changes get a security review.",
"level": "warn"
},
{
"path": "**/migrations/**",
"reason": "Schema migrations are reviewed by the data owner.",
"level": "warn"
},
{
"path": ".github/workflows/**",
"reason": "Pipeline changes can leak secrets.",
"level": "warn",
"aiOnly": true
}
],
"forbidNewDependencies": false,
"blockOn": [
"critical"
],
"requireHumanCommitForAI": false,
"blockDriftForAI": false,
"allowedHosts": [],
"blockUnknownHosts": false,
"approvedVendors": [],
"blockUnapprovedVendors": false,
"ignore": []
}API keys
Keys let the CLI in CI and your own scripts act for the workspace. Owners and admins create them in Settings, API keys, from a signed-in session: a key cannot create another key.
- Name the key after where it will live, such as “GitHub Actions: acme/api”. Choose when it expires: in 30 days, 90 days, a year, or never.
- The key is shown once. Afterwards Settings shows its prefix, scopes, when it was last used and when it expires.
- Revoke stops it at once.
- Send it as
Authorization: Bearer vdk_…. Each key is limited to 30 requests per 10 seconds.
| Scope | Allows | Give it to |
|---|---|---|
| read | List and fetch reports and cards, the sanity index, settings and the list of connections | Dashboards |
| write | Analyse and store reports; post the comment through a connected host | CI pipelines |
| admin | Connections, keys and workspace settings | Automation that manages the workspace |
curl -s "https://visualdiff.ai/api/v1/reports?limit=5" \
-H "Authorization: Bearer $VISUALDIFF_KEY" \
| jq '.reports[] | {repo, prNumber, verdict, risk}'Every endpoint is listed in the API reference. To use a key from CI, see guest mode or an API key.
Security and data
- visualdiff reads pull requests and writes one comment and one status. It never pushes, merges or changes settings in your code host.
- Host tokens, the ArchCanvas key, webhook secrets, notification URLs and signing secrets are encrypted at rest with AES-256-GCM. The API never returns them; the webhook and signing secrets visualdiff generates are shown once, when created.
- Sessions and API keys are stored only as SHA-256 hashes.
- Webhooks are verified by HMAC signature or shared secret before anything in them is read.
- Addresses you enter, such as a self-managed server or ArchCanvas, are fetched over HTTPS only, and private or local network addresses are refused at every redirect.
- The diff and the files needed to resolve imports are fetched and analysed in memory; the report is stored in your workspace.
- The verdict comes from a deterministic engine. If the deployment has a model configured, it writes the summary from counts, paths and finding titles only. The model never sees your code or excerpts from it, and never sets the verdict.
- Logs are one structured line per event. Anything that looks like a secret is reduced to its length and last four characters.
- To keep everything on your own servers, self-host visualdiff; the Enterprise plan on the pricing page covers it.
Troubleshooting
There is no Sign in button
The deployment has no sign-in configured. On a self-hosted deployment, set the database and OIDC variables in self-hosting. Public pull requests and pasted diffs still work from the inbox.
Saving a connection says the deployment has no CREDENTIALS_KEY
Self-hosted only: tokens cannot be stored until the operator sets CREDENTIALS_KEY.
“Private or local hosts cannot be reached from visualdiff”
Your server address resolves to a private network. visualdiff only calls servers that are reachable over public HTTPS. Run it inside your code host’s CI instead: only the runner needs to reach visualdiff.
Deliveries fail with 401
The secret in the host does not match. Saving the connection again with a token makes a new secret; paste it into the webhook. Also check that the URL’s connection id is the one in Settings.
Deliveries from GitHub fail with 400
The webhook’s content type is form-encoded. Set it to application/json.
Deliveries succeed but nothing appears on the pull request
Check the delivery’s answer: ignored gives the reason. If it says accepted, open your inbox: if the report is there, the review ran and posting back failed. Press Test on the connection, and check that the token has the write permissions for comments and statuses listed for your host. Commenting can also be turned off under Review settings, and the status is off until you turn it on. A connection without a token cannot comment.
A push was not reviewed
Updates that do not add commits are ignored on GitLab and Azure DevOps, and a commit reviewed in the last ten minutes is not reviewed again. Check that the webhook subscribes to the pull request events in the table above.
Two jobs keep rewriting the comment
A webhook and a CI job that both pass --comment edit the same comment. Keep commenting in one of them.
An old report is gone
Each workspace keeps its newest 2,000 reports; older ones are removed as new ones arrive.