Tulip explains GitHub pull requests and GitLab merge requests - categorizing and prioritizing the changes - and presenting them on a web page as prose, diagrams, and code.
Every code change appears somewhere in the explanation, so nothing slips past you. Yet you don't have to wade through it all: instead of digging through piles of code to find the logic that matters, you can focus on the algorithms, the design choices, the core functionality. The explanations are top-down: a high-level overview first, then down into the code.
And if you don't know the codebase at all, the prose and the generated diagrams may be enough on their own to understand the PR well enough to accept or reject it.
Tulip uses a headless agent CLI to do the analysis: Claude Code's claude by
default, or Cursor's agent or OpenAI's codex. No API key needed, just a
logged-in CLI.
Click a screenshot to view it full size.
Note
Tulip is part of Visdom, VirtusLab's AI-native SDLC platform.
Tulip fetches the PR and runs it through a few LLM passes, each using a model sized to the job (Haiku, Sonnet, or Opus; see Choosing the agent for the models used with Cursor and Codex):
- Fetch & check out. Get the PR (title, description, diff) via
gh(or the GitHub API) — or, for a GitLab merge request, via the GitLab API — check out its head revision, and write the full diff plus the before-versions of changed files into the checkout, so later passes can read the real code. - Categorize (Sonnet). Split the changes into a few self-contained groups by concern — tests and docs ride along with the code they belong to. Each group gets an attention rating (Read closely / Read through / Skim); groups are ordered most-important first. A second Sonnet pass reviews the groups (when there is more than one) and amends them once if the split is off.
- Split large changes (Sonnet). For each change over a size threshold, decide whether it spans more than one group's concern and, if so, where to cut it — turned into an exact partition in code, so no line is lost. This lets the next step route the pieces of a big multi-concern file to different groups.
- Classify (Haiku, a fresh session per batch). Assign every change to one or more groups and mark it as production or test code. Unfitting changes can propose a new group; generated files and lockfiles are dropped; every change is checked to be covered. A change that lands in several groups is owned by one — its highest-attention group — and only linked from the others.
- Explain (Opus, one session per group; Sonnet review). Write a top-down prose explanation grounded in the real code: open with the overall shape and an orienting diagram — a dependency graph, data flow, class, state, ER, or sequence diagram, as the change calls for — then drill into each change, leading with its interface, folding routine code by default, and showing in full only what the group's attention rating and the must-see cases demand. A change owned by another group is linked, not re-explained. A Sonnet pass reviews each explanation and the writer amends it once; every referenced snippet is checked to appear and every diagram validated to render.
- Render. Assemble everything into one self-contained HTML page: light/dark theme, foldable sidebar table of contents, syntax-highlighted side-by-side diffs, and the attention badges.
The result is a temporary directory with the page; Tulip logs the file:// path
to open.
- Node.js LTS (>= 24) — its bundled
npminstalls Tulip; pnpm is only needed to build from source - A logged-in agent CLI on
PATH— Tulip drives it to analyze the PR:claude(the default), Cursor'sagent(agent login), or OpenAI'scodex(codex login); see Choosing the agent - Optionally, the
ghCLI, logged in — used to fetch PR data when available; Tulip falls back to the GitHub REST API otherwise (setGITHUB_TOKENto raise the unauthenticated rate limit) - For a private GitLab project, a
GITLAB_TOKENenvironment variable holding a personal access token with theread_apiandread_repositoryscopes
Private repositories also need credentials for git fetch. If your git
config rewrites the host to SSH (insteadOf), git uses your SSH key and nothing
else applies. Otherwise git uses the first credential helper that answers: your
own setup first (such as the OS keychain or gh auth setup-git), then a
logged-in gh, then GITHUB_TOKEN; for GitLab, GITLAB_TOKEN instead of gh
and GITHUB_TOKEN. After a successful fetch, git may store that token with your
own credential helper (such as the OS keychain). If your own helper answers with
a credential the host rejects, fix or clear that credential. Tulip fails with a
hint instead of asking for a username and password on the terminal.
Self-hosted GitHub Enterprise Server URLs (e.g.
https://git.example.com/owner/repo/pull/7) work too. Authenticate that host
once with gh auth login --hostname git.example.com; the REST fallback uses the
host's /api/v3 endpoint with GITHUB_TOKEN.
GitLab merge requests work on gitlab.com and self-hosted GitLab, nested groups
included (e.g. https://git.example.com/group/sub/project/-/merge_requests/7).
Use the merge request's own URL: one copied from a tab such as .../diffs, or one
with a ?query or #fragment, is not accepted. Tulip reads merge requests
through the GitLab REST API (no glab needed), sending GITLAB_TOKEN when it is
set (only to the merge request's host); public projects work without it. If
GitLab truncated a very large merge request's diff at its diff limits, Tulip
stops with an error rather than explaining part of it. --serve is GitHub-only
for now.
npm install -g @virtuslab/tulip
# or: pnpm add -g @virtuslab/tulipThis puts a tulip command on your PATH. To run from source instead, see
Development.
tulip <PR URL> [options]Example:
tulip https://github.com/owner/repo/pull/123
tulip https://gitlab.com/group/project/-/merge_requests/45Flags: --diff-threshold <n>, --verbose, --serve, --no-open,
--agent <id>. Run tulip --help for details, or tulip --version for the
build.
On success, Tulip logs a file://... path to the generated HTML page — open it
in a browser.
Tulip uses claude unless you pass --agent with another id from the table
below:
tulip <PR URL> --agent cursor-anthropicThe choice is saved in ~/.config/tulip/config.json ($XDG_CONFIG_HOME/tulip/
if set; %APPDATA%\tulip\ on Windows) and used on later runs until you pass
another --agent.
Each pass asks for a fast, balanced, or strong model, mapped per agent in
src/config.ts:
| Tier | claude |
cursor-anthropic |
cursor-grok |
codex |
|---|---|---|---|---|
| fast | haiku |
claude-haiku-5-5-medium |
grok-4.7-low |
gpt-6-luna |
| balanced | sonnet |
claude-sonnet-5-5-medium |
grok-4.7-medium |
gpt-6-sol |
| strong | opus |
claude-opus-5-5-high |
grok-4.7-high |
gpt-6.1-sol |
cursor-anthropic and cursor-grok both run agent in its read-only ask mode
and trust the temporary checkout, after deleting the PR's .cursor/ and
.claude/ dirs from it: Cursor would otherwise run hooks defined there. They
differ only in the model ids above. Cursor's CLI has no structured-output flag,
so the JSON schema goes into the prompt and the reply is parsed and validated by
Tulip (with one retry), as for claude.
With codex, Tulip runs codex exec with --ignore-user-config and deletes the
PR's .codex/ dir from the checkout first. The model's shell can read only the
checkout and system dirs, has no network, and gets only core env vars (PATH,
HOME, ...). Web search, apps, and plugins are off. The schema goes in a temporary
file passed as --output-schema (made strict, as OpenAI requires); the prompt
goes on stdin. Tulip validates the last agent message of the --json event
stream (with one retry), as for the other agents.
The page's Content Security Policy lets it load only its own assets, so model-written text can't make it fetch a URL on open. Remote images in the PR description don't show either.
tulip <PR URL> --serve--serve runs a local server that hosts the page and opens it (unless
--no-open), adding a box under each category. Type a note and click Post to
GitHub to post it as a PR comment, prefixed with the category name. Each box
posts its own comment. Ctrl+C stops the server.
Serve mode supports GitHub PRs only; with a GitLab merge request URL, Tulip stops with an error before fetching anything.
This needs an authenticated gh CLI for the PR's host (self-hosted included) —
gh auth login --hostname <host>; Tulip checks this before starting. Comments
are posted via gh, so they come from your GitHub account.
pnpm build # compile TypeScript + copy rendering assets
pnpm test # run the test suite (vitest)
pnpm lint # check formatting/lint rules (biome)
pnpm lint:fix # auto-fix formatting/lint issues
pnpm dev <PR URL> # run the CLI from source, no build step (tsx)To put your local build on PATH as tulip, run pnpm add -g . after pnpm build.
npm version 0.9.0This bumps package.json, commits, tags v0.9.0 and pushes. The tag triggers
the Release workflow, which publishes to npm and creates a GitHub release with
generated notes. Run it on master with a clean working tree.
Design decisions are recorded as ADRs in docs/adr/; the LLM prompts live as
plain text in src/prompts/.

