Skip to content

Build a PR reviewer with Agent SDK ​

Build a PR reviewer with the Agent SDK. It classifies changes as trivial, moderate, or large, then approves safe changes or requests human review.

Prerequisites ​

  • Node 22.13 or newer.

  • The @grok/agent-sdk package:

text
npm install -g @grok/agent-sdk
  • A Cursor credential for model turns. Sign in once:
text
agent-sdk login

Create and run the project ​

The preferred Agent SDK workflow uses agent-sdk init and agent-sdk dev.

text
agent-sdk init ./pr-reviewer
cd pr-reviewer
agent-sdk dev

Keep agent-sdk dev running. In a second terminal, run:

text
agent-sdk run --dir . --message "Introduce yourself in one sentence."

Confirm the agent replies.

Add review instructions ​

Replace bot/instructions.md:

md
# PR reviewer

You review GitHub pull requests. Be specific and brief.

For every pull request:

1. Call `inspect_pr` first. Never judge a change you haven't fetched.
2. Match your review to the complexity it reports:
   - `trivial`: read the patches. If the diff does what the title says
     and nothing looks risky, call `submit_review` with verdict
     `approve`.
   - `moderate`: read every patch. Approve only when you understand the
     whole change and see no risk. Otherwise ask for a human review and
     say which files worry you.
   - `large`: call `submit_review` with verdict `request_human_review`
     right away. Use the stats you already have to point the reviewer at
     the biggest files; don't dig further.
3. Never approve a draft. Point out anything surprising, even when you
   approve.

End with one sentence: the verdict and why.

Add the inspect tool ​

Create bot/tools/inspect_pr.ts:

ts
import { defineTool } from "@grok/agent-sdk/tools";
import { z } from "zod";

export type Complexity = "trivial" | "moderate" | "large";

function rateComplexity(linesChanged: number, changedFiles: number): Complexity {
  if (linesChanged <= 25 && changedFiles <= 2) {
    return "trivial";
  }

  if (linesChanged <= 400 && changedFiles <= 15) {
    return "moderate";
  }

  return "large";
}

/** Keep one oversized file from flooding the model's context. */
function trimPatch(patch: string | undefined): string | undefined {
  if (patch === undefined || patch.length <= 3000) {
    return patch;
  }

  return `${patch.slice(0, 3000)}\n[... patch trimmed ...]`;
}

export default defineTool({
  description:
    "Fetch a pull request's title, stats, and per-file patches, plus a deterministic complexity rating (trivial, moderate, or large). Call this before any review decision.",
  inputSchema: z.object({
    prUrl: z
      .string()
      .describe("Pull request URL: https://github.com/owner/repo/pull/123"),
  }),
  async execute({ prUrl }, ctx) {
    const url = new URL(prUrl);
    const [owner, repo, pulls, number] = url.pathname.split("/").filter(Boolean);
    const pull_number = Number.parseInt(number ?? "", 10);
    if (pulls !== "pull" || owner === undefined || Number.isNaN(pull_number)) {
      throw new Error(`Not a pull request URL: ${prUrl}`);
    }

    const octokit = await ctx.host.github.getOctokit();
    const { data: pr } = await octokit.rest.pulls.get({
      owner,
      repo,
      pull_number,
    });

    const { data: files } = await octokit.rest.pulls.listFiles({
      owner,
      repo,
      pull_number,
      per_page: 100,
    });

    const complexity = rateComplexity(
      pr.additions + pr.deletions,
      pr.changed_files
    );

    return {
      title: pr.title,
      author: pr.user?.login,
      state: pr.state,
      draft: pr.draft ?? false,
      additions: pr.additions,
      deletions: pr.deletions,
      changedFiles: pr.changed_files,
      complexity,
      files: files.map((file) => ({
        path: file.filename,
        additions: file.additions,
        deletions: file.deletions,
        ...(complexity === "large" ? {} : { patch: trimPatch(file.patch) }),
      })),
    };
  },
});

Verify complexity classification ​

Run inspect_pr against a small pull request:

text
agent-sdk tool call inspect_pr --dir . \
  --input '{"prUrl":"https://github.com/facebook/react/pull/35623"}'

Confirm the result contains "complexity": "trivial".

Run it against a large pull request:

text
agent-sdk tool call inspect_pr --dir . \
  --input '{"prUrl":"https://github.com/facebook/react/pull/25229"}'

Confirm the result contains "complexity": "large" and no patch fields.

Add the review tool ​

Create bot/tools/submit_review.ts to post each verdict:

ts
import { defineTool } from "@grok/agent-sdk/tools";
import { z } from "zod";

export default defineTool({
  description:
    "Post the review decision to GitHub: approve the pull request, or comment asking for a human review.",
  inputSchema: z.object({
    prUrl: z
      .string()
      .describe("Pull request URL: https://github.com/owner/repo/pull/123"),
    verdict: z.enum(["approve", "request_human_review"]),
    summary: z
      .string()
      .describe("One or two sentences explaining the verdict."),
  }),
  async execute({ prUrl, verdict, summary }, ctx) {
    const url = new URL(prUrl);
    const [owner, repo, pulls, number] = url.pathname.split("/").filter(Boolean);
    const pull_number = Number.parseInt(number ?? "", 10);
    if (pulls !== "pull" || owner === undefined || Number.isNaN(pull_number)) {
      throw new Error(`Not a pull request URL: ${prUrl}`);
    }

    const review =
      verdict === "approve"
        ? { event: "APPROVE" as const, body: `PR reviewer: ${summary}` }
        : {
            event: "COMMENT" as const,
            body: `PR reviewer: this change needs a human review. ${summary}`,
          };

    const octokit = await ctx.host.github.getOctokit();
    await octokit.rest.pulls.createReview({
      owner,
      repo,
      pull_number,
      event: review.event,
      body: review.body,
    });
    return { posted: true, ...review };
  },
});

Use a GitHub credential with write access and a pull request you didn't author.

Review a pull request ​

Review an open pull request:

text
agent-sdk run --dir . \
  --message "Review https://github.com/acme/checkout/pull/42"

Confirm inspect_pr runs before submit_review, then verify the review on GitHub.

Trigger reviews from GitHub ​

Create bot/channels/github.ts:

ts
import {
  defaultGitHubAuth,
  githubChannel,
} from "@grok/agent-sdk/channels/github";

const REVIEW_ACTIONS = new Set(["opened", "reopened", "ready_for_review"]);

export default githubChannel({
  botName: "agent-sdk-pr-reviewer",
  cursorAccount: { repos: ["acme/checkout"] },
  onPullRequest: (ctx, pr) => {
    if (!REVIEW_ACTIONS.has(pr.action) || pr.draft) {
      return null;
    }

    return {
      auth: defaultGitHubAuth(ctx),
      title: `Review ${ctx.repository.fullName}#${pr.number}`,
      context: [
        `Review ${pr.url}. Inspect it first, then submit your verdict with submit_review.`,
      ],
    };
  },
});

Channel-turn context is a readonly string[], so wrap each context block in an array. The context label under progress.commitStatus is a separate string option.

Keep agent-sdk dev running. In a second terminal, replay an open pull request. Pull access is enough; repo admin isn't required:

text
agent-sdk github replay https://github.com/acme/checkout/pull/42 \
  --dir . --action opened

Open the playground and confirm Review acme/checkout#42 contains the trigger, both tool calls, and the verdict.

Run options ​

  1. CLI message
text
 agent-sdk run --dir . --message "Review https://github.com/acme/checkout/pull/42"
  1. Local replay
text
 agent-sdk github replay https://github.com/acme/checkout/pull/42 \
   --dir . --action opened

Deploy the reviewer ​

Follow Deployment and run agent-sdk deploy. After the command reports the runtime is running, open Deployed Agents to see the reviewer and manage it.

Where to go next ​

  • Evals: freeze these two PRs as regression checks so prompt changes can't flip a verdict
  • Tools: more on typed tools, dry-run effects, and direct calls
  • GitHub: fixtures, forwarding, and pulling events from Cursor
  • Building agents with agents: have a coding agent extend the project for you