Skip to content
Scalekit Docs
Talk to an Engineer Dashboard

Build a multi-user GitHub PR summarizer agent

Build a GitHub PR summarizer that binds each connected GitHub account to a secure browser session instead of trusting a client-supplied user ID.

This recipe builds a GitHub PR summarizer with a browser UI and a secure connected-account flow. Each user connects GitHub once, then the app reuses that connected token for later PR summary requests in the same browser session.

The important security rule is straightforward: never accept a user ID from the browser and use it as the Scalekit connected-account identifier. Instead, mint an opaque identifier on the server, store it in your own session store, and complete the flow with user verification for connected accounts.

The finished app does four things:

  • lists the most-discussed open pull requests in a repository
  • fetches each PR’s diff and comment thread through Scalekit’s GitHub connector
  • asks an LLM to summarize the PRs in plain language
  • binds every GitHub connection to a secure browser session instead of a client-supplied identifier

The complete source is available in the render-ai-agent-deploykit repository.

The app runs as a Node web service on Render. It serves an HTML page with a Connect GitHub button and a form for owner and repo.

Under the hood, the flow looks like this:

Browser
▼ GET /
Express server sets signed HTTP-only session cookie
▼ POST /api/auth
Scalekit returns GitHub auth link for a session-bound identifier
▼ GET /user/verify?auth_request_id=...&state=...
Express server validates state and calls verifyConnectedAccountUser
▼ POST /api/summarize { owner, repo }
Scalekit runs GitHub requests with the connected user's token

Create the connector once per Scalekit environment.

  1. Go to app.scalekit.comAgentKit > Connections > Create Connection
  2. Find GitHub and click Create
  3. Follow the setup — Scalekit creates and manages the GitHub OAuth app for you
  4. Note the connection name assigned (e.g. github-qkHFhMip) — you’ll set this as GITHUB_CONNECTION_NAME in your environment

This recipe uses Scalekit’s connected-account verification callback. Without that callback, the app has no trustworthy way to prove which local user session should own the new GitHub connection.

  1. Open the GitHub connector you created in the previous step.
  2. In the Scalekit Dashboard, go to AgentKit > Settings > User verification and set it to Custom user verification.
  3. Set PUBLIC_BASE_URL if you want to pin the callback origin explicitly.
  4. If PUBLIC_BASE_URL is unset, the app falls back to the incoming request origin.
  5. When PUBLIC_BASE_URL is set, the app sends ${PUBLIC_BASE_URL}/user/verify as userVerifyUrl.
Terminal
mkdir render-pr-summarizer && cd render-pr-summarizer
npm init -y
npm install @renderinc/sdk @scalekit-sdk/node openai dotenv express
npm install -D typescript tsx @types/node @types/express
package.json
{
"type": "module",
"scripts": {
"dev": "tsx src/main.ts",
"build": "tsc",
"start": "node dist/main.js"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"strict": true
},
"include": ["src"]
}
Terminal
cp .env.example .env
.env
PORT=3000
# Optional callback-origin override. If omitted, the app uses the incoming request origin.
PUBLIC_BASE_URL=http://localhost:3000
SESSION_SECRET=replace-with-openssl-rand-hex-32
LITELLM_API_KEY=your-api-key
LITELLM_BASE_URL=https://your-litellm-base-url
LITELLM_MODEL=claude-haiku-4-5
SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.com
SCALEKIT_CLIENT_ID=your-scalekit-client-id
SCALEKIT_CLIENT_SECRET=your-scalekit-client-secret
GITHUB_CONNECTION_NAME=your-github-connection-name

Generate SESSION_SECRET with:

Terminal
openssl rand -hex 32

If PUBLIC_BASE_URL is set, it must match the public origin where the app is running because the server uses it to construct userVerifyUrl. If it is omitted, the app falls back to the incoming request origin.

The helper layer creates connected accounts, generates auth links, verifies the callback, and routes GitHub API calls through Scalekit’s connector.

src/scalekit.ts
import "dotenv/config";
import { ScalekitClient } from "@scalekit-sdk/node";
import type { JsonObject } from "@bufbuild/protobuf";
let _scalekit: ScalekitClient | null = null;
function getScalekit(): ScalekitClient {
if (_scalekit) return _scalekit;
if (!process.env.SCALEKIT_ENVIRONMENT_URL || !process.env.SCALEKIT_CLIENT_ID || !process.env.SCALEKIT_CLIENT_SECRET) {
throw new Error("Missing SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, or SCALEKIT_CLIENT_SECRET");
}
_scalekit = new ScalekitClient(
process.env.SCALEKIT_ENVIRONMENT_URL,
process.env.SCALEKIT_CLIENT_ID,
process.env.SCALEKIT_CLIENT_SECRET,
);
return _scalekit;
}
export const scalekit = new Proxy({} as ScalekitClient, {
get(_target, prop) {
return (getScalekit() as unknown as Record<string | symbol, unknown>)[prop];
},
});
const GITHUB_CONNECTION_NAME = process.env.GITHUB_CONNECTION_NAME;
if (!GITHUB_CONNECTION_NAME) {
throw new Error(
"GITHUB_CONNECTION_NAME is required. Copy the connection name from Scalekit Dashboard > Agent Auth > Connectors.",
);
}
export async function getGitHubAuthLink(
identifier: string,
opts: { state: string; userVerifyUrl: string },
): Promise<string> {
await scalekit.actions.getOrCreateConnectedAccount({
connectionName: GITHUB_CONNECTION_NAME,
identifier,
});
const res = await scalekit.actions.getAuthorizationLink({
connectionName: GITHUB_CONNECTION_NAME,
identifier,
state: opts.state,
userVerifyUrl: opts.userVerifyUrl,
});
if (!res.link) {
throw new Error(
`Scalekit did not return a GitHub authorization link for '${GITHUB_CONNECTION_NAME}' and identifier '${identifier}'`,
);
}
return res.link;
}
export async function verifyUser(params: {
authRequestId: string;
identifier: string;
}): Promise<void> {
await scalekit.actions.verifyConnectedAccountUser({
authRequestId: params.authRequestId,
identifier: params.identifier,
});
}
export async function githubTool(
identifier: string,
toolName: string,
toolInput: Record<string, unknown>,
): Promise<JsonObject> {
const res = await scalekit.actions.executeTool({
toolName,
toolInput,
connector: GITHUB_CONNECTION_NAME,
identifier,
});
return res.data ?? {};
}
export async function githubRequest(
identifier: string,
path: string,
options: {
method?: string;
headers?: Record<string, string>;
queryParams?: Record<string, unknown>;
} = {},
) {
const res = await scalekit.actions.request({
connectionName: GITHUB_CONNECTION_NAME,
identifier,
path,
method: options.method ?? "GET",
headers: options.headers,
queryParams: options.queryParams,
});
return res.data;
}

6. Bind the browser session to an opaque identifier

Section titled “6. Bind the browser session to an opaque identifier”

The session layer is the security boundary for the whole app.

Create src/session.ts and store three things:

  • a signed session cookie sent to the browser
  • an opaque usr_... identifier stored on the server
  • a one-time state value stored on the server while OAuth is in flight
src/session.ts
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import type { Request, Response } from "express";
const COOKIE_NAME = "sid";
const STATE_TTL_MS = 10 * 60 * 1000;
interface SessionEntry {
identifier: string;
pendingState?: string;
pendingStateExpiresAt?: number;
connectedAt?: number;
}
const store = new Map<string, SessionEntry>();
function getSecret(): string {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error("SESSION_SECRET is required");
}
return secret;
}
function sign(sessionId: string): string {
const mac = createHmac("sha256", getSecret()).update(sessionId).digest("base64url");
return `${sessionId}.${mac}`;
}
function unsign(signed: string): string | null {
const dot = signed.lastIndexOf(".");
if (dot < 0) return null;
const sessionId = signed.slice(0, dot);
const mac = signed.slice(dot + 1);
const expected = createHmac("sha256", getSecret()).update(sessionId).digest("base64url");
const expectedBuf = Buffer.from(expected);
const macBuf = Buffer.from(mac);
if (expectedBuf.length !== macBuf.length) return null;
return timingSafeEqual(expectedBuf, macBuf) ? sessionId : null;
}
export function requireSession(req: Request, res: Response) {
const cookies = Object.fromEntries(
(req.headers.cookie ?? "")
.split(";")
.flatMap((pair) => {
const eq = pair.indexOf("=");
if (eq < 0) return [];
try {
return [[pair.slice(0, eq).trim(), decodeURIComponent(pair.slice(eq + 1).trim())]];
} catch {
return [];
}
}),
);
const raw = cookies[COOKIE_NAME];
let sessionId = raw ? unsign(raw) : null;
let entry = sessionId ? store.get(sessionId) ?? null : null;
if (!sessionId || !entry) {
sessionId = randomBytes(32).toString("base64url");
entry = { identifier: "" };
store.set(sessionId, entry);
}
// The cookie only carries a random opaque session id. HMAC signing is enough
// to detect tampering because the sensitive identifier stays server-side.
const protoHeader = req.get("x-forwarded-proto");
const requestIsSecure = req.secure || protoHeader?.split(",")[0]?.trim() === "https";
const secure =
process.env.NODE_ENV === "production" ||
process.env.PUBLIC_BASE_URL?.startsWith("https://") === true ||
requestIsSecure;
const parts = [
`${COOKIE_NAME}=${sign(sessionId)}`,
"HttpOnly",
"SameSite=Lax",
"Path=/",
`Max-Age=${7 * 24 * 60 * 60}`,
];
if (secure) parts.push("Secure");
res.setHeader("Set-Cookie", parts.join("; "));
return { entry };
}
export function mintIdentifier(entry: SessionEntry): string {
if (!entry.identifier) {
entry.identifier = `usr_${randomBytes(16).toString("hex")}`;
}
return entry.identifier;
}
export function setPendingState(entry: SessionEntry, state: string): void {
entry.pendingState = state;
entry.pendingStateExpiresAt = Date.now() + STATE_TTL_MS;
}
export function consumePendingState(entry: SessionEntry, incoming: string): boolean {
const stored = entry.pendingState;
const expiresAt = entry.pendingStateExpiresAt;
entry.pendingState = undefined;
entry.pendingStateExpiresAt = undefined;
if (!stored || !expiresAt || Date.now() > expiresAt) return false;
const storedBuf = Buffer.from(stored);
const incomingBuf = Buffer.from(incoming);
if (storedBuf.length !== incomingBuf.length) return false;
return timingSafeEqual(storedBuf, incomingBuf);
}
export function markConnected(entry: SessionEntry): void {
entry.connectedAt = Date.now();
}
export function isConnected(entry: SessionEntry): boolean {
return entry.connectedAt !== undefined;
}

The task layer now accepts a server-side identifier, not a browser-supplied userId.

src/tasks.ts
import { task } from "@renderinc/sdk/workflows";
import OpenAI from "openai";
import { githubRequest, githubTool, getGitHubAuthLink } from "./scalekit.js";
export interface PRSummaryInput {
identifier: string;
owner: string;
repo: string;
}
const fetchOpenPRs = task(
{ name: "fetchOpenPRs", retry: { maxRetries: 3, waitDurationMs: 1000 } },
async function fetchOpenPRs(identifier: string, owner: string, repo: string) {
const raw = await githubTool(identifier, "github_pull_requests_list", {
owner,
repo,
state: "open",
});
const r = raw as Record<string, unknown>;
const list = Array.isArray(raw)
? raw
: Array.isArray(r.array) ? r.array
: Array.isArray(r.pull_requests) ? r.pull_requests
: Array.isArray(r.data) ? r.data
: null;
if (!list) {
throw new Error(`Unexpected response shape: ${JSON.stringify(raw).slice(0, 200)}`);
}
type PRItem = { number: number; title: string; comments: number; review_comments: number };
return (list as PRItem[])
.sort((a, b) => (b.comments + b.review_comments) - (a.comments + a.review_comments))
.slice(0, 5);
},
);
const fetchPRDetails = task(
{ name: "fetchPRDetails", retry: { maxRetries: 3, waitDurationMs: 1000 } },
async function fetchPRDetails(identifier: string, owner: string, repo: string, prNumber: number) {
const [diffRaw, commentsRaw] = await Promise.all([
githubRequest(identifier, `/repos/${owner}/${repo}/pulls/${prNumber}`, {
headers: { Accept: "application/vnd.github.diff" },
}),
githubRequest(identifier, `/repos/${owner}/${repo}/issues/${prNumber}/comments`),
]);
const diff = typeof diffRaw === "string" ? diffRaw.slice(0, 3000) : "";
const comments = Array.isArray(commentsRaw) ? commentsRaw : [];
return { diff, comments };
},
);
export const setupGitHubAuthTask = task(
{ name: "setupGitHubAuth" },
async function setupGitHubAuth(params: {
identifier: string;
state: string;
userVerifyUrl: string;
}) {
const link = await getGitHubAuthLink(params.identifier, {
state: params.state,
userVerifyUrl: params.userVerifyUrl,
});
return { authLink: link };
},
);

The HTTP server owns the secure flow. It issues the session cookie, starts the GitHub auth flow, validates the callback, and blocks summary requests until the session is connected.

src/server.ts
import crypto from "node:crypto";
import express from "express";
import { setupGitHubAuthTask, summarizePRsTask } from "./tasks.js";
import { verifyUser } from "./scalekit.js";
import {
consumePendingState,
isConnected,
markConnected,
mintIdentifier,
requireSession,
setPendingState,
} from "./session.js";
import { renderHomePage } from "./views.js";
import type { Request } from "express";
function getConfiguredPublicBaseUrl(): string | null {
const value = process.env.PUBLIC_BASE_URL;
return value ? value.replace(/\/$/, "") : null;
}
function getRequestOrigin(req: Request): string {
const configured = getConfiguredPublicBaseUrl();
if (configured) return configured;
const protoHeader = req.get("x-forwarded-proto");
const proto = protoHeader?.split(",")[0]?.trim() || req.protocol || "http";
const host = req.get("x-forwarded-host") || req.get("host");
if (!host) {
throw new Error("Could not determine the public origin for this request");
}
return `${proto}://${host}`;
}
export function startServer(): void {
const app = express();
app.set("trust proxy", true);
app.use(express.json());
app.get("/", (req, res) => {
const { entry } = requireSession(req, res);
res.type("html").send(renderHomePage({ connected: isConnected(entry) }));
});
app.post("/api/auth", async (req, res) => {
const { entry } = requireSession(req, res);
const identifier = mintIdentifier(entry);
const state = crypto.randomUUID();
setPendingState(entry, state);
const result = await setupGitHubAuthTask({
identifier,
state,
userVerifyUrl: `${getRequestOrigin(req)}/user/verify`,
});
res.json({ authLink: result.authLink });
});
app.get("/user/verify", async (req, res) => {
const { auth_request_id, state } = req.query as Record<string, string>;
if (!auth_request_id || !state) {
res.status(400).send("Missing auth_request_id or state");
return;
}
const { entry } = requireSession(req, res);
if (!entry.identifier) {
res.status(400).send("No pending authorization for this session");
return;
}
if (!consumePendingState(entry, state)) {
res.status(400).send("Invalid or expired state");
return;
}
await verifyUser({
authRequestId: auth_request_id,
identifier: entry.identifier,
});
markConnected(entry);
res.redirect("/");
});
app.post("/api/summarize", async (req, res) => {
const { entry } = requireSession(req, res);
if (!isConnected(entry)) {
res.status(401).json({ error: "Connect your GitHub account first" });
return;
}
const { owner, repo } = req.body as { owner?: string; repo?: string };
if (!owner || !repo) {
res.status(400).json({ error: "owner and repo are required" });
return;
}
const result = await summarizePRsTask({ identifier: entry.identifier, owner, repo });
res.json(result);
});
}

The UI only asks for owner and repo. It does not ask for a user identifier. After a successful callback, the page shows a connected banner and changes Step 1 to Reconnect GitHub for the current session.

src/views.ts
export function renderHomePage({ connected }: { connected: boolean }): string {
const connectedBanner = connected
? `<div class="connected-banner">&#10003; GitHub connected</div>`
: `<div class="not-connected-banner">Connect GitHub before summarizing pull requests.</div>`;
const authButtonLabel = connected ? "Reconnect GitHub" : "Connect GitHub";
return `<!DOCTYPE html>
<html lang="en">
<body>
${connectedBanner}
<button id="auth-btn" onclick="connectGitHub()">${authButtonLabel}</button>
<p>Public repositories work with any connected GitHub account. Private repositories only work if the connected account has access.</p>
<input id="sum-owner" aria-label="Repository owner" />
<input id="sum-repo" aria-label="Repository name" />
<button id="sum-btn" onclick="summarize()">Summarize</button>
<pre id="summary-output"></pre>
<script>
async function connectGitHub() {
const res = await fetch('/api/auth', { method: 'POST' });
const data = await res.json();
window.location.href = data.authLink;
}
async function summarize() {
const owner = document.getElementById('sum-owner').value.trim();
const repo = document.getElementById('sum-repo').value.trim();
const output = document.getElementById('summary-output');
output.textContent = 'Loading summary...';
const res = await fetch('/api/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ owner, repo }),
});
const data = await res.json();
if (!res.ok) {
output.textContent = data.error ?? 'Request failed';
return;
}
output.textContent = data.summary;
}
</script>
</body>
</html>`;
}
  1. Copy .env.example to .env and fill in your values.
  2. Run npm install.
  3. Run npm run dev.
  4. Open http://localhost:3000.
  5. Click Connect GitHub.
  6. Finish the GitHub OAuth flow and return to the app.
  7. Confirm the page shows GitHub connected and the button label changes to Reconnect GitHub.
  8. Enter owner and repo, then generate a summary.

When the callback succeeds, the page shows a connected banner. The browser session cookie is HttpOnly, and the server rejects tampered cookies because they fail HMAC validation.

Public repositories work with any connected GitHub account. Private repositories only work if the connected account has access.

Render deploys the app as a web service from render.yaml.

Set these environment variables in Render:

  • LITELLM_API_KEY
  • LITELLM_BASE_URL
  • LITELLM_MODEL
  • SCALEKIT_ENVIRONMENT_URL
  • SCALEKIT_CLIENT_ID
  • SCALEKIT_CLIENT_SECRET
  • GITHUB_CONNECTION_NAME
  • SESSION_SECRET
  • PUBLIC_BASE_URL

When PUBLIC_BASE_URL is set, use the deployed service origin, for example https://your-service.onrender.com.

If PUBLIC_BASE_URL is omitted, the app falls back to the incoming request origin for the OAuth callback URL. When you deploy from the included render.yaml, Render auto-generates SESSION_SECRET for you.

  • Shared session store: The sample stores session data in memory. Use Redis or a database-backed shared store in production.
  • Short-lived OAuth state: The sample expires the pending state after 10 minutes and consumes it after a single callback.
  • Session-bound identifier: The browser never chooses the identifier that Scalekit uses to look up the connected account.
  • Connector-backed GitHub requests: The sample routes both PR listing and PR detail fetches through Scalekit so the connected user’s token is used consistently.