> **Building with AI coding agents?** If you're using an AI coding agent, install the official Scalekit plugin. It gives your agent full awareness of the Scalekit API — reducing hallucinations and enabling faster, more accurate code generation.
>
> - **Claude Code**: `/plugin marketplace add scalekit-inc/claude-code-authstack` then `/plugin install <auth-type>@scalekit-auth-stack`
> - **GitHub Copilot CLI**: `copilot plugin marketplace add scalekit-inc/github-copilot-authstack` then `copilot plugin install <auth-type>@scalekit-auth-stack`
> - **Codex**: run the bash installer, restart, then open Plugin Directory and enable `<auth-type>`
> - **Skills CLI** (Windsurf, Cline, 40+ agents): `npx skills add scalekit-inc/skills --list` then `--skill <skill-name>`
>
> `<auth-type>` / `<skill-name>`: `agentkit`, `full-stack-auth`, `mcp-auth`, `modular-sso`, `modular-scim` — [Full setup guide](https://docs.scalekit.com/dev-kit/build-with-ai/)

---

# 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](/agentkit/user-verification/).

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](https://github.com/scalekit-developers/render-ai-agent-deploykit) repository. You can also [watch the video walkthrough](https://youtu.be/w3atzSkKE1w) to see the full setup and demo end-to-end.

> note: Why this cookbook stays TypeScript-only
>
> This sample uses Render's Node SDK and ships as a TypeScript project, so the cookbook mirrors the repo and stays TypeScript-only. For multi-language examples of the verification flow itself, see [user verification for connected accounts](/agentkit/user-verification/).

## What you are building

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:

```text
Browser (original tab)                  Browser (new tab)
  │                                       │
  ▼ GET /                                 │
Express server sets signed session cookie │
  │                                       │
  ▼ POST /api/auth                        │
Scalekit returns GitHub auth link         │
  │                                       │
  │  opens auth link ─────────────────►   ▼
  │                                     GitHub OAuth consent
  │                                       │
  │  polls GET /api/auth/status           ▼
  │  ◄─── Scalekit API: ACTIVE ──►  Scalekit verifies account
  │
  ▼ page auto-reloads
  │
  ▼ POST /api/summarize { repository }
Scalekit runs GitHub requests with the connected user's token
```

The OAuth flow opens in a **new tab** so the app page stays intact. The original tab polls the Scalekit API until the connected account becomes `ACTIVE`, then auto-reloads to show the connected state.

## 1. Set up the GitHub connector

Create the connector once per Scalekit environment.

1. Go to [app.scalekit.com](https://app.scalekit.com) → **AgentKit** > **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

> caution: Connection names are unique per environment
>
> Scalekit generates a unique GitHub connection name for each environment. Do not copy one from a tutorial or another project. Always use the exact value from your own Scalekit Dashboard.

## 2. Configure user verification (required)

Scalekit's user verification setting controls what happens after a user completes GitHub OAuth. **You must choose a mode in the dashboard before the app will work end-to-end.** Go to **AgentKit > Settings > User verification** in the [Scalekit dashboard](https://app.scalekit.com).

| Mode | When to use | What happens after OAuth |
|------|-------------|--------------------------|
| **Scalekit users only** | Development and testing | Scalekit verifies the user internally. The connected account goes `ACTIVE` automatically. The app detects this by polling the Scalekit API. |
| **Custom user verification** | Production | Scalekit redirects the browser to your app's `/user/verify` callback. The server calls `verifyConnectedAccountUser` to activate the account. The app also polls the Scalekit API as a fallback. |

The app works in **both modes** without code changes. If you skip this step entirely, the connected account may never reach `ACTIVE` status and the app will stay stuck on "Waiting for GitHub authorization."

> caution: This step is required
>
> This is the most common setup mistake. If you deploy the app, set all environment variables, and complete GitHub OAuth but the app never shows "GitHub connected," check this dashboard setting first.

For the full verification model, see [user verification for connected accounts](/agentkit/user-verification/).

## 3. Create the project

```bash title="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
```

```json title="package.json"
{
  "type": "module",
  "scripts": {
    "dev": "tsx src/main.ts",
    "build": "tsc",
    "start": "node dist/main.js"
  }
}
```

```json title="tsconfig.json"
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "strict": true
  },
  "include": ["src"]
}
```

## 4. Configure environment variables

```bash title="Terminal"
cp .env.example .env
```

```bash title=".env"
PORT=3000
SESSION_SECRET=replace-with-openssl-rand-hex-32

OPENAI_API_KEY=your-api-key
OPENAI_MODEL=gpt-4.1-mini
# Leave OPENAI_BASE_URL empty for OpenAI direct.
# Set it to a proxy URL for LiteLLM, Azure OpenAI, Ollama, etc.
# OPENAI_BASE_URL=https://your-litellm-proxy.example.com

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

# Optional — the app auto-detects its public URL from proxy headers.
# Only set this if you need to pin the callback origin explicitly.
# PUBLIC_BASE_URL=http://localhost:3000
```

Generate `SESSION_SECRET` with:

```bash title="Terminal"
openssl rand -hex 32
```

> note: Any OpenAI-compatible API works
>
> The sample uses the `openai` npm package with a configurable `baseURL`. Set `OPENAI_BASE_URL` to route calls through LiteLLM, Azure OpenAI, Ollama, or any other OpenAI-compatible endpoint. The API key must match the endpoint it is sent to.

> note: PUBLIC_BASE_URL is optional
>
> The app infers its public URL from Render's `x-forwarded-proto` and `host` proxy headers automatically. You only need to set `PUBLIC_BASE_URL` if you are behind a custom domain or an unusual reverse proxy. On first deploy to Render, you can leave it unset — the app works without it.

## 5. Add Scalekit auth helpers

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

```typescript title="src/scalekit.ts"

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,
  });
}

/**
 * Check the connected account status via Scalekit API.
 * Returns true when the account is active (OAuth complete and verified).
 */
export async function isAccountActive(identifier: string): Promise<boolean> {
  try {
    const res = await scalekit.actions.getConnectedAccount({
      connectionName: GITHUB_CONNECTION_NAME,
      identifier,
    });
    // ConnectorStatus.ACTIVE === 1
    return res.connectedAccount?.status === 1;
  } catch {
    return false;
  }
}

export async function githubTool(
  identifier: string,
  toolName: string,
  toolInput: Record<string, unknown>,
): Promise {
  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;
}
```

> caution: Use the exact connector name
>
> The `connector` value in `executeTool` must be the full connection name from your own Scalekit environment, not the generic provider string `"github"`.

## 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

```typescript title="src/session.ts"

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;
}
```

> caution: Never trust query params for identity
>
> Read the identifier from your own session store, not from the URL and not from the request body. The callback query string only proves that Scalekit completed an OAuth flow. Your server must decide which local user session owns that new connection.

## 7. Add the tasks

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

```typescript title="src/tasks.ts"

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 };
  },
);

// ---- LLM summary ----

function createOpenAIClient(): OpenAI {
  const apiKey = process.env.OPENAI_API_KEY;
  if (!apiKey) throw new Error("OPENAI_API_KEY not set");
  return new OpenAI({
    apiKey,
    ...(process.env.OPENAI_BASE_URL && { baseURL: process.env.OPENAI_BASE_URL }),
  });
}

const generateSummary = task(
  { name: "generateSummary", retry: { maxRetries: 3, waitDurationMs: 2000 } },
  async function generateSummary(
    prs: { number: number; title: string; diff: string; comments: { body?: string }[] }[],
    owner: string,
    repo: string,
  ): Promise<string> {
    if (prs.length === 0) return "No open pull requests found in this repository.";

    const client = createOpenAIClient();
    const prBlocks = prs
      .map((pr) => {
        const bodies = pr.comments.slice(0, 5).map((c) => `> ${(c.body ?? "").slice(0, 300)}`).join("\n");
        return `PR #${pr.number} — ${pr.title}\n${bodies || "No comments."}\nDiff:\n${pr.diff || "(not available)"}`;
      })
      .join("\n\n---\n\n");

    const response = await client.chat.completions.create({
      model: process.env.OPENAI_MODEL ?? "gpt-4.1-mini",
      messages: [
        {
          role: "system",
          content:
            "Summarize each PR in one paragraph (3-4 sentences) for a team lead. " +
            "Cover what it does, how much discussion happened, and whether it looks close to merging.",
        },
        { role: "user", content: `Repository: ${owner}/${repo}\n\n${prBlocks}` },
      ],
    });

    return response.choices[0].message.content ?? "(no summary generated)";
  },
);

// ---- Root task ----

export const summarizePRsTask = task(
  { name: "summarizePRs", timeoutSeconds: 120 },
  async function summarizePRs(input: PRSummaryInput) {
    const { identifier, owner, repo } = input;
    const topPRs = await fetchOpenPRs(identifier, owner, repo);

    if (topPRs.length === 0) {
      return { repository: `${owner}/${repo}`, prsAnalyzed: [] as string[], summary: "No open pull requests found." };
    }

    const details = await Promise.all(
      topPRs.map((pr) => fetchPRDetails(identifier, owner, repo, pr.number)),
    );

    const prsForSummary = topPRs.map((pr, i) => ({
      number: pr.number,
      title: pr.title,
      diff: details[i].diff,
      comments: details[i].comments as { body?: string }[],
    }));

    const summary = await generateSummary(prsForSummary, owner, repo);

    return {
      repository: `${owner}/${repo}`,
      prsAnalyzed: topPRs.map((p) => `#${p.number}: ${p.title}`),
      summary,
    };
  },
);
```

## 8. Wire the HTTP server

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.

```typescript title="src/server.ts"

  consumePendingState,
  isConnected,
  markConnected,
  mintIdentifier,
  requireSession,
  setPendingState,
} from "./session.js";

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) }));
  });

  // Polled by the original tab while the OAuth tab is open.
  // Checks the in-memory session first, then queries the Scalekit API
  // to detect when the connected account becomes ACTIVE.
  app.get("/api/auth/status", async (req, res) => {
    const { entry } = requireSession(req, res);
    if (isConnected(entry)) {
      res.json({ connected: true });
      return;
    }
    if (entry.identifier && await isAccountActive(entry.identifier)) {
      markConnected(entry);
      res.json({ connected: true });
      return;
    }
    res.json({ connected: false });
  });

  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 });
  });

  // Callback for custom user verification mode. When Scalekit is
  // configured in "Scalekit users only" mode, this route may not fire —
  // the /api/auth/status polling handles that case via the Scalekit API.
  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);
    // This handler runs in the OAuth tab. Render a minimal page
    // telling the user to close it — the original tab is polling
    // /api/auth/status and will auto-reload.
    res.type("html").send(renderAuthCompletePage());
  });

  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;
    }

    // The UI sends { repository: "https://github.com/owner/repo" } or "owner/repo".
    // Parse the string into separate owner and repo values.
    const { repository } = req.body as { repository?: string };
    if (!repository) {
      res.status(400).json({ error: "Provide a GitHub repository URL or owner/repo name." });
      return;
    }

    let owner: string | undefined;
    let repo: string | undefined;
    try {
      const url = new URL(repository);
      const segments = url.pathname.split("/").filter(Boolean);
      owner = segments[0];
      repo = segments[1]?.replace(/\.git$/, "");
    } catch {
      const parts = repository.split("/");
      owner = parts[0];
      repo = parts[1]?.replace(/\.git$/, "");
    }

    if (!owner || !repo) {
      res.status(400).json({ error: "Provide a GitHub repository URL or owner/repo name." });
      return;
    }

    const result = await summarizePRsTask({ identifier: entry.identifier, owner, repo });
    res.json(result);
  });
}
```

## 9. Render the browser UI

The UI only asks for a repository. It does not ask for a user identifier. After a successful connection, the page auto-reloads and shows a connected banner.

The key change from a naive implementation: `connectGitHub()` opens the auth link in a **new tab** instead of navigating the current page. This keeps the app intact even if the OAuth redirect chain doesn't return cleanly. The original tab polls `/api/auth/status` and auto-reloads when the Scalekit API reports the account as `ACTIVE`.

```typescript title="src/views.ts"
export function renderAuthCompletePage(): string {
  return `<!DOCTYPE html>
  <html lang="en">
    <body style="display:flex;align-items:center;justify-content:center;min-height:100vh">
      <div style="text-align:center">
        <h1>&#10003; GitHub connected</h1>
        You can close this tab and return to the app. The original page will update automatically.

      </div>
    </body>
  </html>`;
}

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>
      <div id="auth-result"></div>
      <input id="sum-repository" placeholder="https://github.com/owner/repo" aria-label="GitHub repository" />
      <button id="sum-btn" onclick="summarize()">Summarize</button>
      <pre id="summary-output"></pre>
      <script>
        let authPollTimer = null;

        function startAuthPoll() {
          if (authPollTimer) return;
          authPollTimer = setInterval(async () => {
            try {
              const r = await fetch('/api/auth/status');
              const d = await r.json();
              if (d.connected) {
                clearInterval(authPollTimer);
                authPollTimer = null;
                window.location.reload();
              }
            } catch {}
          }, 2500);
        }

        async function connectGitHub() {
          const btn = document.getElementById('auth-btn');
          const resultEl = document.getElementById('auth-result');
          btn.disabled = true;
          resultEl.textContent = 'Generating authorization link...';

          try {
            const res = await fetch('/api/auth', { method: 'POST' });
            const data = await res.json();
            if (!res.ok) throw new Error(data.error || 'Request failed');

            // Open OAuth in a new tab — this page stays intact.
            window.open(data.authLink, '_blank');
            resultEl.textContent = 'Waiting for GitHub authorization — complete the flow in the new tab.';
            startAuthPoll();
          } catch (err) {
            resultEl.textContent = err.message;
            btn.disabled = false;
          }
        }

        async function summarize() {
          const repository = document.getElementById('sum-repository').value.trim();
          const output = document.getElementById('summary-output');
          if (!repository) { alert('Enter a GitHub repository'); return; }
          output.textContent = 'Loading summary...';

          const res = await fetch('/api/summarize', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ repository }),
          });
          const data = await res.json();

          if (!res.ok) {
            output.textContent = data.error ?? 'Request failed';
            return;
          }

          output.textContent = data.summary;
        }
      </script>
    </body>
  </html>`;
}
```

## 10. Run locally

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**. A new tab opens for the GitHub OAuth flow.
6. Complete the OAuth consent in the new tab.
7. The new tab shows "GitHub connected — you can close this tab" (in custom verification mode) or a Scalekit success page (in Scalekit-users-only mode).
8. The original tab auto-detects the connection and reloads, showing a **GitHub connected** banner.
9. Enter a repository URL or `owner/repo`, then generate a summary.

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

## 11. Deploy to Render

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

Set these environment variables in Render:

| Variable | Required | Notes |
|----------|----------|-------|
| `SCALEKIT_ENVIRONMENT_URL` | Yes | From Scalekit dashboard → Developers → API Credentials |
| `SCALEKIT_CLIENT_ID` | Yes | Same location |
| `SCALEKIT_CLIENT_SECRET` | Yes | Same location |
| `GITHUB_CONNECTION_NAME` | Yes | From AgentKit → Connectors |
| `OPENAI_API_KEY` | Yes | OpenAI key or proxy token |
| `OPENAI_BASE_URL` | No | Leave empty for OpenAI direct. Set for LiteLLM/Azure/Ollama. |
| `OPENAI_MODEL` | No | Default: `gpt-4.1-mini` |
| `SESSION_SECRET` | Auto | `render.yaml` auto-generates this |
| `PUBLIC_BASE_URL` | No | Auto-detected from proxy headers. Only needed behind a custom domain. |

After deploying, configure user verification in the Scalekit dashboard ([step 2](#2-configure-user-verification-required)). The app will not complete the GitHub connection flow without this.

## Production notes

- **User verification mode**: Switch to **Custom user verification** in the Scalekit dashboard before going to production. This ensures your backend confirms which session owns each new connection.
- **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.

## Next steps

- Read [user verification for connected accounts](/agentkit/user-verification/) for the full verification model and additional examples.
- Read [authorize a user](/agentkit/tools/authorize/) for the status-polling pattern used to detect when a connected account becomes `ACTIVE`.
- Open the [render-ai-agent-deploykit](https://github.com/scalekit-developers/render-ai-agent-deploykit) repository to compare the full implementation against the snippets in this cookbook.


---

## More Scalekit documentation

| Resource | What it contains | When to use it |
|----------|-----------------|----------------|
| [/llms.txt](/llms.txt) | Structured index with routing hints per product area | Start here — find which documentation set covers your topic before loading full content |
| [/llms-full.txt](/llms-full.txt) | Complete documentation for all Scalekit products in one file | Use when you need exhaustive context across multiple products or when the topic spans several areas |
| [sitemap-0.xml](https://docs.scalekit.com/sitemap-0.xml) | Full URL list of every documentation page | Use to discover specific page URLs you can fetch for targeted, page-level answers |
