How to Build an AI Chat App for Your Claude Pipelines Using the Claude Agent SDK


Your team probably has scripts and data sitting in directories on a Linux server. People who want to inspect them either SSH in or ping the one engineer who knows where everything is. This guide walks you through building a we based AI chat app where a teammate logs in with their company Google account, picks a pipeline they are allowed to see, and chats with Claude about it. Claude reads the files server-side, and the answer streams back into the browser word by word.

This is written for junior developers. If you have never set up a Node.js project, used TypeScript, run a database migration, or configured nginx, that is fine. Every step gives you the exact command, the exact file contents, and how to check it worked before you continue.

1. What we are building

We are building one small web application that runs on your server. It has five jobs:

  1. Log a user in with Google Single Sign-On (SSO). SSO means “sign in once with an account you already have” – here, the company Google account. We only accept your company domain and only people you have explicitly invited.
  2. Show the user the pipelines (directories) they have been granted access to.
  3. Let them chat with Claude. Each chat runs a Claude Agent SDK session on the server, locked to the directories that user is allowed to read.
  4. Stream Claude’s answer into the browser in real time using SSE (Server-Sent Events, a one-way channel where the server pushes text to the browser over a single long-lived HTTP connection).
  5. Let an admin define reusable prompt shortcuts that appear as buttons in the chat.

Architecture

Here is the request flow, front to back:

Browser (React app)
    |
    |  HTTPS
    v
nginx  (reverse proxy, terminates TLS on port 443)
    |
    |  plain HTTP on 127.0.0.1:3000
    v
Express server (Node.js + TypeScript)
    |-- /auth/*        Google OAuth login, sets a session cookie
    |-- /api/*         chat, pipelines, admin, shortcuts
    |-- SSE stream     pushes Claude's tokens back to the browser
    |
    |-- Prisma  ---->  SQLite database (users, grants, conversations, shortcuts)
    |
    |-- Claude Agent SDK session (Read / Grep / Glob only)
             |
             v
        Filesystem: ~/example-pipeline/  (only granted directories)

A reverse proxy is a server that sits in front of your app, receives requests from the internet, and forwards them to your app running privately on the same machine. It handles HTTPS so your app does not have to.

The whole thing is two Node projects in one folder:

  • server/ – the Express backend, database, auth, and Agent SDK glue.
  • web/ – the React frontend built with Vite (a fast build tool and dev server for frontend code).

In production the backend also serves the built frontend, so there is a single origin and no cross-origin cookie headaches.

2. Prerequisites

You need:

  • A Linux server (this guide assumes Ubuntu 22.04 or 24.04) that you can SSH into.
  • A domain name pointing at that server, for example chat.yourcompany.com. You need this for HTTPS.
  • A Google Workspace account on your company domain (to create the SSO credentials).
  • An Anthropic API key.

2.1 Check your Node.js version

Claude’s SDK needs Node.js 18 or newer. We will use Node 20. Check what you have:

node -v

If that prints v20.x.x or higher, skip to section 2.2. If the command is not found or the version is older, install Node 20 with nvm (Node Version Manager, a tool that installs and switches between Node versions without root):

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

Close and reopen your terminal (or run source ~/.bashrc), then:

nvm install 20
nvm use 20

Verify:

node -v

Expected output: v20.19.0 (any v20.x is fine).

2.2 Get an Anthropic API key

  1. Go to https://console.anthropic.com.
  2. Sign in or create an account.
  3. In the left sidebar click “API Keys”.
  4. Click “Create Key”, give it a name like pipeline-chat, and copy the value. It starts with sk-ant-.
  5. Store it somewhere safe now. The console will not show it again.

We will put this key in a .env file in the next section. A .env file is a plain text file holding secret settings; it is never committed to git.

2.3 Create the example pipeline

So we have something to chat about, create a fake pipeline directory:

mkdir -p ~/example-pipeline
printf '%s\n' "step 1: pull data" "step 2: transform" "step 3: publish" > ~/example-pipeline/README.txt
printf '%s\n' "#!/usr/bin/env bash" "echo running the daily job" > ~/example-pipeline/run.sh

Verify:

ls ~/example-pipeline

Expected output:

README.txt  run.sh

3. Project scaffold

Create the project folder and the two sub-projects.

mkdir -p ~/pipeline-chat
mkdir -p ~/pipeline-chat/server/src
mkdir -p ~/pipeline-chat/server/prisma
mkdir -p ~/pipeline-chat/web/src

3.1 Initialise the backend

Move into the server folder. We run each command on its own line so you can see exactly what each one does.

cd ~/pipeline-chat/server

Create a package.json (the file that lists your project’s dependencies and scripts):

npm init -y

Now install the runtime dependencies:

npm install express cookie-parser dotenv @prisma/client google-auth-library @anthropic-ai/claude-agent-sdk

Install the development dependencies (TypeScript, the runner, type definitions, and the Prisma command-line tool):

npm install -D typescript tsx prisma @types/node @types/express @types/cookie-parser

tsx runs TypeScript files directly without a separate compile step, which keeps things simple.

Now replace the generated package.json. Open ~/pipeline-chat/server/package.json and make it exactly this:

{
  "name": "pipeline-chat-server",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "start": "tsx src/index.ts",
    "invite": "tsx src/invite.ts",
    "prisma:migrate": "prisma migrate dev",
    "prisma:generate": "prisma generate"
  },
  "dependencies": {
    "@anthropic-ai/claude-agent-sdk": "^0.1.0",
    "@prisma/client": "^6.2.0",
    "cookie-parser": "^1.4.7",
    "dotenv": "^16.4.7",
    "express": "^4.21.2",
    "google-auth-library": "^9.15.0"
  },
  "devDependencies": {
    "@types/cookie-parser": "^1.4.8",
    "@types/express": "^4.17.21",
    "@types/node": "^22.10.5",
    "prisma": "^6.2.0",
    "tsx": "^4.19.2",
    "typescript": "^5.7.2"
  }
}

"type": "module" tells Node to use modern import syntax. The version numbers are the ones that existed when this guide was written; npm install above may pull slightly newer ones, which is fine.

Create ~/pipeline-chat/server/tsconfig.json (the file that configures the TypeScript compiler):

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2022"],
    "types": ["node"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "noEmit": true
  },
  "include": ["src"]
}

noEmit is true because we run the code with tsx and never produce compiled .js files for the backend.

Verify the install worked:

npx tsc --noEmit

Expected output: nothing (it prints errors only if something is wrong; an empty response means success). It may complain that src has no files yet – that is fine, we add them next.

3.2 Initialise the frontend

cd ~/pipeline-chat/web
npm create vite@latest . -- --template react-ts

If it asks whether to proceed in a non-empty directory, choose “Yes”. This scaffolds a React + TypeScript app. Now install its dependencies:

npm install

Replace ~/pipeline-chat/web/vite.config.ts with this. The proxy block tells the Vite dev server to forward /api and /auth requests to the backend on port 3000 during development, so your browser talks to one origin:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: {
      "/api": {
        target: "http://localhost:3000",
        changeOrigin: true,
      },
      "/auth": {
        target: "http://localhost:3000",
        changeOrigin: true,
      },
    },
  },
});

Verify the frontend scaffold runs:

npm run dev

Expected output includes a line like Local: http://localhost:5173/. Press Ctrl+C to stop it; we will come back to it after the backend is built.

4. Database with Prisma and SQLite

We store users, access grants, conversations, and shortcuts in SQLite (a database that lives in a single file, needs no separate server, and is perfect for an internal tool). We talk to it through Prisma, an ORM.

An ORM (Object-Relational Mapping) is a library that lets you read and write database rows as normal objects in your code instead of writing raw SQL.

4.1 The schema

A Prisma schema describes your tables. Create ~/pipeline-chat/server/prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id            String         @id @default(cuid())
  email         String         @unique
  name          String?
  isAdmin       Boolean        @default(false)
  createdAt     DateTime       @default(now())
  sessions      Session[]
  grants        Grant[]
  conversations Conversation[]
}

model Session {
  id        String   @id @default(cuid())
  token     String   @unique
  userId    String
  user      User     @relation(fields: [userId], references: [id])
  expiresAt DateTime
  createdAt DateTime @default(now())
}

model Grant {
  id        String   @id @default(cuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id])
  path      String
  createdAt DateTime @default(now())

  @@unique([userId, path])
}

model Conversation {
  id             String    @id @default(cuid())
  userId         String
  user           User      @relation(fields: [userId], references: [id])
  path           String
  agentSessionId String?
  createdAt      DateTime  @default(now())
  messages       Message[]
}

model Message {
  id             String       @id @default(cuid())
  conversationId String
  conversation   Conversation @relation(fields: [conversationId], references: [id])
  role           String
  content        String
  createdAt      DateTime     @default(now())
}

model Shortcut {
  id        String   @id @default(cuid())
  label     String
  prompt    String
  createdAt DateTime @default(now())
}

4.2 The environment file

Create ~/pipeline-chat/server/.env. Prisma reads this automatically, and so does our app. Fill in your own values where noted:

DATABASE_URL="file:./dev.db"
ANTHROPIC_API_KEY="sk-ant-REPLACE-WITH-YOUR-KEY"
GOOGLE_CLIENT_ID="REPLACE-LATER.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="REPLACE-LATER"
OAUTH_REDIRECT_URI="http://localhost:3000/auth/google/callback"
ALLOWED_HD="yourcompany.com"
SESSION_SECRET="REPLACE-WITH-A-RANDOM-STRING"
PIPELINES_BASE="/home/ubuntu"
PORT=3000
NODE_ENV=development

ALLOWED_HD is your Google Workspace domain (the “hosted domain”). PIPELINES_BASE is the folder whose sub-directories can be granted; ~/example-pipeline lives under /home/ubuntu, so that works. Generate a strong SESSION_SECRET:

openssl rand -hex 32

Copy that output into SESSION_SECRET in the .env file. We fill in the two GOOGLE_ values in section 5.

Protect the file so other users cannot read your secrets:

chmod 600 ~/pipeline-chat/server/.env

4.3 Run the first migration

A migration is a versioned change to your database structure. When you run one, Prisma looks at your schema, works out the SQL needed to create or change tables, saves that SQL as a file you can review and re-run later, and applies it.

cd ~/pipeline-chat/server
npm run prisma:migrate -- --name init

Expected output ends with something like:

Your database is now in sync with your schema.
...
Generated Prisma Client

This created prisma/dev.db (the SQLite file), a prisma/migrations/ folder, and the Prisma Client (the generated code you import to query the database). Verify the file exists:

ls prisma

Expected output:

dev.db  migrations  schema.prisma

5. Google SSO

We use Google’s OAuth 2.0 with OpenID Connect. OIDC (OpenID Connect) is a thin layer on top of OAuth that, in addition to granting access, gives you a verified ID token stating who the user is (their email, name, and domain). That is what lets us do login.

5.1 Create OAuth credentials in Google Cloud Console

Do this click by click:

  1. Go to https://console.cloud.google.com.
  2. In the top bar, click the project dropdown, then “New Project”. Name it pipeline-chat and click “Create”. Wait for it to finish, then select it in the project dropdown.
  3. In the left menu go to “APIs and Services”, then “OAuth consent screen”.
  4. Choose “Internal” (this restricts login to your Workspace domain automatically) and click “Create”.
  5. Fill in App name (Pipeline Chat), User support email (yours), and Developer contact email (yours). Click “Save and Continue” through the remaining steps, then “Back to Dashboard”.
  6. In the left menu click “Credentials”, then “Create Credentials”, then “OAuth client ID”.
  7. Application type: “Web application”. Name: pipeline-chat-web.
  8. Under “Authorized redirect URIs” click “Add URI” and enter, for local development: http://localhost:3000/auth/google/callback
  9. Click “Add URI” again and add your production one (you will use this later): https://chat.yourcompany.com/auth/google/callback
  10. Click “Create”. A dialog shows your Client ID and Client secret. Copy both.

A redirect URI is the exact address Google sends the user back to after they approve login. It must match one of the URIs you registered here, character for character, or Google refuses the request.

Paste the two values into ~/pipeline-chat/server/.env:

GOOGLE_CLIENT_ID="123456789-abcdef.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="GOCSPX-your-secret-here"

5.2 Environment loader

Create ~/pipeline-chat/server/src/env.ts. This loads the .env file and fails loudly if anything required is missing:

import "dotenv/config";

function required(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return value;
}

export const env = {
  port: Number(process.env.PORT ?? 3000),
  nodeEnv: process.env.NODE_ENV ?? "development",
  isProd: (process.env.NODE_ENV ?? "development") === "production",
  databaseUrl: required("DATABASE_URL"),
  anthropicApiKey: required("ANTHROPIC_API_KEY"),
  googleClientId: required("GOOGLE_CLIENT_ID"),
  googleClientSecret: required("GOOGLE_CLIENT_SECRET"),
  oauthRedirectUri: required("OAUTH_REDIRECT_URI"),
  allowedHd: required("ALLOWED_HD"),
  sessionSecret: required("SESSION_SECRET"),
  pipelinesBase: required("PIPELINES_BASE"),
};

The Agent SDK reads ANTHROPIC_API_KEY from the environment on its own, and import "dotenv/config" puts it there, so we do not pass the key manually.

5.3 The database client

Create ~/pipeline-chat/server/src/db.ts:

import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

5.4 The auth routes

Create ~/pipeline-chat/server/src/auth.ts. This handles the full login flow, checks the hd claim (the domain the account belongs to), checks the user was invited, and sets a session cookie.

A session cookie is a small token stored in the browser that the browser sends back on every request, so the server remembers you are logged in without asking for your password again.

import { Router } from "express";
import crypto from "node:crypto";
import { OAuth2Client } from "google-auth-library";
import { env } from "./env.js";
import { prisma } from "./db.js";

export const authRouter = Router();

const oauthClient = new OAuth2Client(
  env.googleClientId,
  env.googleClientSecret,
  env.oauthRedirectUri,
);

const SESSION_COOKIE = "sid";
const STATE_COOKIE = "oauth_state";
const SESSION_DAYS = 7;

// Step 1: send the user to Google.
authRouter.get("/google", (req, res) => {
  // "state" is a random value we store in a cookie and check on the way back.
  // It protects against CSRF: an attacker cannot forge a callback because they
  // do not know this value.
  const state = crypto.randomBytes(16).toString("hex");

  res.cookie(STATE_COOKIE, state, {
    httpOnly: true,
    sameSite: "lax",
    secure: env.isProd,
    signed: true,
    maxAge: 10 * 60 * 1000,
  });

  const url = oauthClient.generateAuthUrl({
    access_type: "online",
    scope: ["openid", "email", "profile"],
    state,
    prompt: "select_account",
    hd: env.allowedHd,
  });

  res.redirect(url);
});

// Step 2: Google sends the user back here with a code.
authRouter.get("/google/callback", async (req, res) => {
  try {
    const returnedState = String(req.query.state ?? "");
    const savedState = req.signedCookies[STATE_COOKIE];
    res.clearCookie(STATE_COOKIE);

    if (!returnedState || returnedState !== savedState) {
      res.status(400).send("Invalid state. Please try logging in again.");
      return;
    }

    const code = String(req.query.code ?? "");
    if (!code) {
      res.status(400).send("Missing authorization code.");
      return;
    }

    const { tokens } = await oauthClient.getToken(code);
    if (!tokens.id_token) {
      res.status(400).send("No ID token returned by Google.");
      return;
    }

    const ticket = await oauthClient.verifyIdToken({
      idToken: tokens.id_token,
      audience: env.googleClientId,
    });
    const payload = ticket.getPayload();

    if (!payload || !payload.email || !payload.email_verified) {
      res.status(403).send("Your Google account email is not verified.");
      return;
    }

    // Domain check: only accept accounts on our company domain.
    if (payload.hd !== env.allowedHd) {
      res.status(403).send("This app is restricted to company accounts.");
      return;
    }

    // Invite-only check: the email must already exist in our User table.
    const user = await prisma.user.findUnique({
      where: { email: payload.email },
    });
    if (!user) {
      res.status(403).send("You have not been invited to this app yet.");
      return;
    }

    // Keep the name fresh.
    if (payload.name && payload.name !== user.name) {
      await prisma.user.update({
        where: { id: user.id },
        data: { name: payload.name },
      });
    }

    // Create a session row and set the cookie.
    const token = crypto.randomBytes(32).toString("hex");
    const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
    await prisma.session.create({
      data: { token, userId: user.id, expiresAt },
    });

    res.cookie(SESSION_COOKIE, token, {
      httpOnly: true,
      sameSite: "lax",
      secure: env.isProd,
      expires: expiresAt,
      path: "/",
    });

    res.redirect("/");
  } catch (err) {
    console.error("OAuth callback failed:", err);
    res.status(500).send("Login failed. Check the server logs.");
  }
});

authRouter.post("/logout", async (req, res) => {
  const token = req.cookies[SESSION_COOKIE];
  if (token) {
    await prisma.session.deleteMany({ where: { token } });
  }
  res.clearCookie(SESSION_COOKIE, { path: "/" });
  res.json({ ok: true });
});

export { SESSION_COOKIE };

CSRF (Cross-Site Request Forgery) is an attack where another website secretly makes your browser send a request to our app using your logged-in cookie. The random state value and the SameSite=Lax cookie setting are our defences against it.

5.5 Auth middleware

Middleware is a function Express runs before your route handler; it can check the request and either continue or reject it. Create ~/pipeline-chat/server/src/middleware.ts:

import type { Request, Response, NextFunction } from "express";
import { prisma } from "./db.js";
import { SESSION_COOKIE } from "./auth.js";

export interface AuthedUser {
  id: string;
  email: string;
  name: string | null;
  isAdmin: boolean;
}

export async function requireAuth(
  req: Request,
  res: Response,
  next: NextFunction,
): Promise<void> {
  const token = req.cookies[SESSION_COOKIE];
  if (!token) {
    res.status(401).json({ error: "Not logged in" });
    return;
  }

  const session = await prisma.session.findUnique({
    where: { token },
    include: { user: true },
  });

  if (!session || session.expiresAt < new Date()) {
    res.status(401).json({ error: "Session expired" });
    return;
  }

  const user: AuthedUser = {
    id: session.user.id,
    email: session.user.email,
    name: session.user.name,
    isAdmin: session.user.isAdmin,
  };
  (req as Request & { user: AuthedUser }).user = user;
  next();
}

export function requireAdmin(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const user = (req as Request & { user?: AuthedUser }).user;
  if (!user || !user.isAdmin) {
    res.status(403).json({ error: "Admin only" });
    return;
  }
  next();
}

export function getUser(req: Request): AuthedUser {
  return (req as Request & { user: AuthedUser }).user;
}

5.6 Invite the first admin

Because login is invite-only, you cannot log in until at least one user exists. Create a small command-line script, ~/pipeline-chat/server/src/invite.ts:

import { prisma } from "./db.js";

async function main(): Promise<void> {
  const email = process.argv[2];
  const isAdmin = process.argv.includes("--admin");

  if (!email) {
    console.error('Usage: npm run invite -- [email protected] [--admin]');
    process.exit(1);
  }

  const user = await prisma.user.upsert({
    where: { email },
    update: { isAdmin },
    create: { email, isAdmin },
  });

  console.log(`Invited ${user.email} (admin: ${user.isAdmin})`);
}

main()
  .then(() => process.exit(0))
  .catch((err) => {
    console.error(err);
    process.exit(1);
  });

Invite yourself as an admin (use your real company email):

cd ~/pipeline-chat/server
npm run invite -- [email protected] --admin

Expected output:

Invited [email protected] (admin: true)

6. The session manager

This is the piece that talks to Claude. For each user message we run one Claude Agent SDK turn, restricted to reading only the granted directory, with only the Read, Grep, and Glob tools. We stream the text out token by token, and we remember the SDK session id so follow-up questions keep context (multi-turn).

The Agent SDK is Anthropic’s library for running Claude with tools (like reading files) on your own machine, under rules you set.

Create ~/pipeline-chat/server/src/sessionManager.ts:

import path from "node:path";
import { query } from "@anthropic-ai/claude-agent-sdk";
import type { Conversation } from "@prisma/client";

// If a conversation has been idle longer than this, we start a fresh Claude
// session instead of resuming the old one.
const IDLE_MS = 30 * 60 * 1000;

// Track last activity per conversation, and which conversations are mid-answer.
const lastActivity = new Map<string, number>();
const running = new Set<string>();

// True only if "child" is the same as or inside "parent".
function isInside(child: string, parent: string): boolean {
  const rel = path.relative(parent, child);
  return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}

export interface TurnResult {
  text: string;
  sessionId: string | null;
}

export async function runAgentTurn(
  convo: Conversation,
  userText: string,
  onToken: (token: string) => void,
): Promise<TurnResult> {
  if (running.has(convo.id)) {
    throw new Error(
      "This conversation is already generating a reply. Wait for it to finish.",
    );
  }

  const grantedPath = path.resolve(convo.path);

  // Idle timeout: only resume the previous session if it is still recent.
  const last = lastActivity.get(convo.id) ?? 0;
  const resumeId =
    Date.now() - last > IDLE_MS ? null : convo.agentSessionId ?? null;

  running.add(convo.id);
  lastActivity.set(convo.id, Date.now());

  let finalText = "";
  let sessionId: string | null = resumeId;

  try {
    const response = query({
      prompt: userText,
      options: {
        cwd: grantedPath,
        allowedTools: ["Read", "Grep", "Glob"],
        permissionMode: "default",
        model: "claude-sonnet-4-5",
        includePartialMessages: true,
        ...(resumeId ? { resume: resumeId } : {}),
        // The real security boundary: reject any tool or any path
        // that falls outside the granted directory.
        canUseTool: async (toolName: string, input: Record<string, unknown>) => {
          if (!["Read", "Grep", "Glob"].includes(toolName)) {
            return {
              behavior: "deny" as const,
              message: `Tool ${toolName} is not allowed in this app.`,
            };
          }
          const requested = (input.file_path ?? input.path) as
            | string
            | undefined;
          if (requested) {
            const target = path.resolve(grantedPath, requested);
            if (!isInside(target, grantedPath)) {
              return {
                behavior: "deny" as const,
                message: "Access outside the granted directory is blocked.",
              };
            }
          }
          return { behavior: "allow" as const, updatedInput: input };
        },
      },
    });

    for await (const message of response) {
      // Capture the session id so we can resume next turn.
      if (message.type === "system" && message.subtype === "init") {
        sessionId = message.session_id;
      }

      // Real-time text: partial "stream_event" messages carry text deltas.
      if (message.type === "stream_event") {
        const event = message.event as {
          type: string;
          delta?: { type: string; text?: string };
        };
        if (
          event.type === "content_block_delta" &&
          event.delta?.type === "text_delta" &&
          typeof event.delta.text === "string"
        ) {
          finalText += event.delta.text;
          onToken(event.delta.text);
        }
      }

      // Final message with the complete result.
      if (message.type === "result") {
        sessionId = message.session_id ?? sessionId;
        if (
          message.subtype === "success" &&
          typeof message.result === "string" &&
          message.result.length > 0 &&
          finalText.length === 0
        ) {
          finalText = message.result;
        }
      }
    }

    lastActivity.set(convo.id, Date.now());
    return { text: finalText, sessionId };
  } finally {
    running.delete(convo.id);
  }
}

Three things are doing the security work here: allowedTools limits Claude to read-only tools, cwd points the session at one directory, and canUseTool rejects any file path that resolves outside that directory. The idle timeout and the running set stop a stale or double-fired request from causing trouble.

Note on the model name: claude-sonnet-4-5 is correct at the time of writing. If the SDK rejects it, run npm ls @anthropic-ai/claude-agent-sdk and check the package README for the current model identifiers.

7. The SSE chat endpoint

Now we wire chat into HTTP. The browser first POSTs the user’s message, then opens an SSE stream to receive Claude’s reply.

SSE (Server-Sent Events) is a browser feature where you open one long-lived HTTP connection and the server pushes named events down it as text. The browser reads them with the built-in EventSource object. It is simpler than WebSockets and perfect for one-way streaming like an AI reply.

7.1 The chat routes

Create ~/pipeline-chat/server/src/chat.ts:

import { Router } from "express";
import type { Response } from "express";
import path from "node:path";
import { prisma } from "./db.js";
import { requireAuth, getUser } from "./middleware.js";
import { runAgentTurn } from "./sessionManager.js";

export const chatRouter = Router();

// Helper to send one named SSE event as JSON.
function sendEvent(res: Response, event: string, data: unknown): void {
  res.write(`event: ${event}\n`);
  res.write(`data: ${JSON.stringify(data)}\n\n`);
}

// List the pipelines this user may open.
chatRouter.get("/pipelines", requireAuth, async (req, res) => {
  const user = getUser(req);
  const grants = await prisma.grant.findMany({
    where: { userId: user.id },
    orderBy: { path: "asc" },
  });
  res.json(
    grants.map((g) => ({ path: g.path, name: path.basename(g.path) })),
  );
});

// Start a conversation against one granted pipeline.
chatRouter.post("/conversations", requireAuth, async (req, res) => {
  const user = getUser(req);
  const requestedPath = String(req.body.path ?? "");

  const grant = await prisma.grant.findUnique({
    where: { userId_path: { userId: user.id, path: requestedPath } },
  });
  if (!grant) {
    res.status(403).json({ error: "You do not have access to that pipeline." });
    return;
  }

  const convo = await prisma.conversation.create({
    data: { userId: user.id, path: requestedPath },
  });
  res.json({ id: convo.id, path: convo.path });
});

// Return the message history of a conversation.
chatRouter.get("/conversations/:id/messages", requireAuth, async (req, res) => {
  const user = getUser(req);
  const convo = await prisma.conversation.findUnique({
    where: { id: req.params.id },
  });
  if (!convo || convo.userId !== user.id) {
    res.status(404).json({ error: "Not found" });
    return;
  }
  const messages = await prisma.message.findMany({
    where: { conversationId: convo.id },
    orderBy: { createdAt: "asc" },
  });
  res.json(messages.map((m) => ({ role: m.role, content: m.content })));
});

// Store a user message (does not generate a reply yet).
chatRouter.post("/conversations/:id/messages", requireAuth, async (req, res) => {
  const user = getUser(req);
  const text = String(req.body.text ?? "").trim();
  if (!text) {
    res.status(400).json({ error: "Message text is required." });
    return;
  }

  const convo = await prisma.conversation.findUnique({
    where: { id: req.params.id },
  });
  if (!convo || convo.userId !== user.id) {
    res.status(404).json({ error: "Not found" });
    return;
  }

  await prisma.message.create({
    data: { conversationId: convo.id, role: "user", content: text },
  });
  res.json({ ok: true });
});

// SSE: generate and stream the reply to the latest user message.
chatRouter.get("/conversations/:id/stream", requireAuth, async (req, res) => {
  const user = getUser(req);
  const convo = await prisma.conversation.findUnique({
    where: { id: req.params.id },
  });
  if (!convo || convo.userId !== user.id) {
    res.status(404).end();
    return;
  }

  const lastUser = await prisma.message.findFirst({
    where: { conversationId: convo.id, role: "user" },
    orderBy: { createdAt: "desc" },
  });
  if (!lastUser) {
    res.status(400).end();
    return;
  }

  // SSE headers. "X-Accel-Buffering: no" tells nginx not to buffer the stream.
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache, no-transform");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("X-Accel-Buffering", "no");
  res.flushHeaders();

  // A comment line every 20s keeps the connection alive through proxies.
  const keepAlive = setInterval(() => res.write(": ping\n\n"), 20000);

  try {
    const result = await runAgentTurn(convo, lastUser.content, (token) => {
      sendEvent(res, "token", { text: token });
    });

    await prisma.message.create({
      data: {
        conversationId: convo.id,
        role: "assistant",
        content: result.text,
      },
    });
    if (result.sessionId) {
      await prisma.conversation.update({
        where: { id: convo.id },
        data: { agentSessionId: result.sessionId },
      });
    }

    sendEvent(res, "done", { text: result.text });
  } catch (err) {
    console.error("Stream error:", err);
    sendEvent(res, "error", { message: String(err) });
  } finally {
    clearInterval(keepAlive);
    res.end();
  }
});

7.2 The React chat component

Create ~/pipeline-chat/web/src/api.ts, a tiny helper for JSON requests:

export async function api<T>(
  url: string,
  options: RequestInit = {},
): Promise<T> {
  const res = await fetch(url, {
    ...options,
    headers: { "Content-Type": "application/json", ...(options.headers ?? {}) },
    credentials: "same-origin",
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(body.error ?? `Request failed: ${res.status}`);
  }
  return res.json() as Promise<T>;
}

Create ~/pipeline-chat/web/src/components/Chat.tsx:

import { useEffect, useState } from "react";
import { api } from "../api";

interface Pipeline {
  path: string;
  name: string;
}
interface Shortcut {
  id: string;
  label: string;
  prompt: string;
}
interface ChatMessage {
  role: "user" | "assistant";
  content: string;
}

export function Chat() {
  const [pipelines, setPipelines] = useState<Pipeline[]>([]);
  const [shortcuts, setShortcuts] = useState<Shortcut[]>([]);
  const [selected, setSelected] = useState<string>("");
  const [conversationId, setConversationId] = useState<string | null>(null);
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [input, setInput] = useState("");
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    api<Pipeline[]>("/api/pipelines").then(setPipelines).catch(console.error);
    api<Shortcut[]>("/api/shortcuts").then(setShortcuts).catch(console.error);
  }, []);

  async function openPipeline(pipelinePath: string) {
    setSelected(pipelinePath);
    const convo = await api<{ id: string }>("/api/conversations", {
      method: "POST",
      body: JSON.stringify({ path: pipelinePath }),
    });
    setConversationId(convo.id);
    setMessages([]);
  }

  async function send(text: string) {
    if (!conversationId || !text.trim() || busy) return;
    setBusy(true);
    setInput("");
    setMessages((prev) => [...prev, { role: "user", content: text }]);

    await api(`/api/conversations/${conversationId}/messages`, {
      method: "POST",
      body: JSON.stringify({ text }),
    });

    // Add an empty assistant message we will fill as tokens arrive.
    setMessages((prev) => [...prev, { role: "assistant", content: "" }]);

    const es = new EventSource(
      `/api/conversations/${conversationId}/stream`,
      { withCredentials: true },
    );

    es.addEventListener("token", (e) => {
      const data = JSON.parse((e as MessageEvent).data) as { text: string };
      setMessages((prev) => {
        const copy = [...prev];
        copy[copy.length - 1] = {
          role: "assistant",
          content: copy[copy.length - 1].content + data.text,
        };
        return copy;
      });
    });

    es.addEventListener("done", () => {
      es.close();
      setBusy(false);
    });

    es.addEventListener("error", () => {
      es.close();
      setBusy(false);
      setMessages((prev) => {
        const copy = [...prev];
        const lastMsg = copy[copy.length - 1];
        if (lastMsg && lastMsg.content === "") {
          copy[copy.length - 1] = {
            role: "assistant",
            content: "[The stream failed. Check the server logs.]",
          };
        }
        return copy;
      });
    });
  }

  return (
    <div style={{ maxWidth: 800, margin: "0 auto", padding: 16 }}>
      <h2>Pipeline Chat</h2>

      <div style={{ marginBottom: 12 }}>
        <label>Pipeline: </label>
        <select
          value={selected}
          onChange={(e) => openPipeline(e.target.value)}
        >
          <option value="">-- choose a pipeline --</option>
          {pipelines.map((p) => (
            <option key={p.path} value={p.path}>
              {p.name}
            </option>
          ))}
        </select>
      </div>

      {conversationId && (
        <>
          <div style={{ marginBottom: 12 }}>
            {shortcuts.map((s) => (
              <button
                key={s.id}
                disabled={busy}
                onClick={() => send(s.prompt)}
                style={{ marginRight: 8, marginBottom: 8 }}
              >
                {s.label}
              </button>
            ))}
          </div>

          <div
            style={{
              border: "1px solid #ccc",
              borderRadius: 8,
              padding: 12,
              minHeight: 300,
              marginBottom: 12,
              whiteSpace: "pre-wrap",
            }}
          >
            {messages.map((m, i) => (
              <div key={i} style={{ marginBottom: 12 }}>
                <strong>{m.role === "user" ? "You" : "Claude"}:</strong>{" "}
                {m.content}
              </div>
            ))}
          </div>

          <form
            onSubmit={(e) => {
              e.preventDefault();
              send(input);
            }}
            style={{ display: "flex", gap: 8 }}
          >
            <input
              style={{ flex: 1, padding: 8 }}
              value={input}
              disabled={busy}
              placeholder="Ask about this pipeline..."
              onChange={(e) => setInput(e.target.value)}
            />
            <button type="submit" disabled={busy}>
              {busy ? "..." : "Send"}
            </button>
          </form>
        </>
      )}
    </div>
  );
}

EventSource automatically sends the session cookie because the request is same-origin, so the SSE endpoint is authenticated just like any other route.

8. Grants and the admin page

Admins invite users, grant per-directory access, and manage shortcuts. The grant screen uses a filesystem scanner that lists the directories under PIPELINES_BASE.

8.1 The admin routes

Create ~/pipeline-chat/server/src/admin.ts:

import { Router } from "express";
import fs from "node:fs";
import path from "node:path";
import { prisma } from "./db.js";
import { requireAuth, requireAdmin } from "./middleware.js";
import { env } from "./env.js";

export const adminRouter = Router();

// Shortcuts can be read by any logged-in user (used by the chat UI).
adminRouter.get("/shortcuts", requireAuth, async (_req, res) => {
  const shortcuts = await prisma.shortcut.findMany({
    orderBy: { createdAt: "asc" },
  });
  res.json(shortcuts);
});

// Everything below is admin-only.
adminRouter.use("/admin", requireAuth, requireAdmin);

adminRouter.get("/admin/users", async (_req, res) => {
  const users = await prisma.user.findMany({
    orderBy: { email: "asc" },
    include: { grants: true },
  });
  res.json(
    users.map((u) => ({
      id: u.id,
      email: u.email,
      name: u.name,
      isAdmin: u.isAdmin,
      grants: u.grants.map((g) => g.path),
    })),
  );
});

adminRouter.post("/admin/users", async (req, res) => {
  const email = String(req.body.email ?? "").trim().toLowerCase();
  const isAdmin = Boolean(req.body.isAdmin);
  if (!email) {
    res.status(400).json({ error: "Email is required." });
    return;
  }
  const user = await prisma.user.upsert({
    where: { email },
    update: { isAdmin },
    create: { email, isAdmin },
  });
  res.json({ id: user.id, email: user.email, isAdmin: user.isAdmin });
});

// Filesystem scanner: list directories directly under PIPELINES_BASE.
adminRouter.get("/admin/scan", async (_req, res) => {
  const base = path.resolve(env.pipelinesBase);
  const entries = fs.readdirSync(base, { withFileTypes: true });
  const dirs = entries
    .filter((e) => e.isDirectory() && !e.name.startsWith("."))
    .map((e) => ({ name: e.name, path: path.join(base, e.name) }));
  res.json(dirs);
});

// Replace the full set of grants for one user.
adminRouter.post("/admin/grants", async (req, res) => {
  const userId = String(req.body.userId ?? "");
  const paths: string[] = Array.isArray(req.body.paths) ? req.body.paths : [];

  const user = await prisma.user.findUnique({ where: { id: userId } });
  if (!user) {
    res.status(404).json({ error: "User not found." });
    return;
  }

  const base = path.resolve(env.pipelinesBase);
  for (const p of paths) {
    const resolved = path.resolve(p);
    if (resolved !== base && !resolved.startsWith(base + path.sep)) {
      res.status(400).json({ error: `Path outside base: ${p}` });
      return;
    }
  }

  await prisma.grant.deleteMany({ where: { userId } });
  await prisma.grant.createMany({
    data: paths.map((p) => ({ userId, path: path.resolve(p) })),
  });
  res.json({ ok: true, count: paths.length });
});

adminRouter.post("/admin/shortcuts", async (req, res) => {
  const label = String(req.body.label ?? "").trim();
  const prompt = String(req.body.prompt ?? "").trim();
  if (!label || !prompt) {
    res.status(400).json({ error: "Label and prompt are required." });
    return;
  }
  const shortcut = await prisma.shortcut.create({ data: { label, prompt } });
  res.json(shortcut);
});

adminRouter.delete("/admin/shortcuts/:id", async (req, res) => {
  await prisma.shortcut.delete({ where: { id: req.params.id } });
  res.json({ ok: true });
});

8.2 The admin React page

Create ~/pipeline-chat/web/src/components/Admin.tsx:

import { useEffect, useState } from "react";
import { api } from "../api";

interface AdminUser {
  id: string;
  email: string;
  name: string | null;
  isAdmin: boolean;
  grants: string[];
}
interface DirEntry {
  name: string;
  path: string;
}
interface Shortcut {
  id: string;
  label: string;
  prompt: string;
}

export function Admin() {
  const [users, setUsers] = useState<AdminUser[]>([]);
  const [dirs, setDirs] = useState<DirEntry[]>([]);
  const [shortcuts, setShortcuts] = useState<Shortcut[]>([]);
  const [newEmail, setNewEmail] = useState("");
  const [newAdmin, setNewAdmin] = useState(false);
  const [selectedUser, setSelectedUser] = useState<string>("");
  const [checkedPaths, setCheckedPaths] = useState<Set<string>>(new Set());
  const [scLabel, setScLabel] = useState("");
  const [scPrompt, setScPrompt] = useState("");

  function reload() {
    api<AdminUser[]>("/api/admin/users").then(setUsers).catch(console.error);
    api<Shortcut[]>("/api/shortcuts").then(setShortcuts).catch(console.error);
  }

  useEffect(() => {
    reload();
    api<DirEntry[]>("/api/admin/scan").then(setDirs).catch(console.error);
  }, []);

  async function invite() {
    await api("/api/admin/users", {
      method: "POST",
      body: JSON.stringify({ email: newEmail, isAdmin: newAdmin }),
    });
    setNewEmail("");
    setNewAdmin(false);
    reload();
  }

  function pickUser(id: string) {
    setSelectedUser(id);
    const u = users.find((x) => x.id === id);
    setCheckedPaths(new Set(u ? u.grants : []));
  }

  function togglePath(p: string) {
    setCheckedPaths((prev) => {
      const next = new Set(prev);
      if (next.has(p)) next.delete(p);
      else next.add(p);
      return next;
    });
  }

  async function saveGrants() {
    await api("/api/admin/grants", {
      method: "POST",
      body: JSON.stringify({
        userId: selectedUser,
        paths: Array.from(checkedPaths),
      }),
    });
    reload();
  }

  async function addShortcut() {
    await api("/api/admin/shortcuts", {
      method: "POST",
      body: JSON.stringify({ label: scLabel, prompt: scPrompt }),
    });
    setScLabel("");
    setScPrompt("");
    reload();
  }

  async function deleteShortcut(id: string) {
    await api(`/api/admin/shortcuts/${id}`, { method: "DELETE" });
    reload();
  }

  return (
    <div style={{ maxWidth: 800, margin: "0 auto", padding: 16 }}>
      <h2>Admin</h2>

      <h3>Invite user</h3>
      <input
        placeholder="[email protected]"
        value={newEmail}
        onChange={(e) => setNewEmail(e.target.value)}
      />
      <label style={{ marginLeft: 8 }}>
        <input
          type="checkbox"
          checked={newAdmin}
          onChange={(e) => setNewAdmin(e.target.checked)}
        />{" "}
        admin
      </label>
      <button style={{ marginLeft: 8 }} onClick={invite}>
        Invite
      </button>

      <h3>Users</h3>
      <ul>
        {users.map((u) => (
          <li key={u.id}>
            {u.email} {u.isAdmin ? "(admin)" : ""} - grants:{" "}
            {u.grants.length}
          </li>
        ))}
      </ul>

      <h3>Grant directories</h3>
      <select value={selectedUser} onChange={(e) => pickUser(e.target.value)}>
        <option value="">-- choose a user --</option>
        {users.map((u) => (
          <option key={u.id} value={u.id}>
            {u.email}
          </option>
        ))}
      </select>
      {selectedUser && (
        <div style={{ marginTop: 8 }}>
          {dirs.map((d) => (
            <label key={d.path} style={{ display: "block" }}>
              <input
                type="checkbox"
                checked={checkedPaths.has(d.path)}
                onChange={() => togglePath(d.path)}
              />{" "}
              {d.name} <span style={{ color: "#888" }}>({d.path})</span>
            </label>
          ))}
          <button style={{ marginTop: 8 }} onClick={saveGrants}>
            Save grants
          </button>
        </div>
      )}

      <h3>Shortcuts</h3>
      <input
        placeholder="Label (button text)"
        value={scLabel}
        onChange={(e) => setScLabel(e.target.value)}
      />
      <br />
      <textarea
        placeholder="Prompt text sent to Claude"
        value={scPrompt}
        onChange={(e) => setScPrompt(e.target.value)}
        style={{ width: "100%", height: 60, marginTop: 8 }}
      />
      <button onClick={addShortcut}>Add shortcut</button>
      <ul>
        {shortcuts.map((s) => (
          <li key={s.id}>
            <strong>{s.label}</strong>: {s.prompt}{" "}
            <button onClick={() => deleteShortcut(s.id)}>delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

8.3 App shell, login, and the server entry point

Create ~/pipeline-chat/web/src/components/Login.tsx:

export function Login() {
  return (
    <div style={{ maxWidth: 400, margin: "80px auto", textAlign: "center" }}>
      <h2>Pipeline Chat</h2>
      <p>Sign in with your company Google account.</p>
      <a href="/auth/google">
        <button style={{ padding: "10px 20px" }}>Sign in with Google</button>
      </a>
    </div>
  );
}

Replace ~/pipeline-chat/web/src/App.tsx:

import { useEffect, useState } from "react";
import { api } from "./api";
import { Login } from "./components/Login";
import { Chat } from "./components/Chat";
import { Admin } from "./components/Admin";

interface Me {
  email: string;
  name: string | null;
  isAdmin: boolean;
}

export default function App() {
  const [me, setMe] = useState<Me | null>(null);
  const [loading, setLoading] = useState(true);
  const [tab, setTab] = useState<"chat" | "admin">("chat");

  useEffect(() => {
    api<Me>("/api/me")
      .then(setMe)
      .catch(() => setMe(null))
      .finally(() => setLoading(false));
  }, []);

  async function logout() {
    await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
    setMe(null);
  }

  if (loading) return <p style={{ padding: 16 }}>Loading...</p>;
  if (!me) return <Login />;

  return (
    <div>
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          padding: 12,
          borderBottom: "1px solid #ddd",
        }}
      >
        <div>
          <button onClick={() => setTab("chat")}>Chat</button>
          {me.isAdmin && (
            <button onClick={() => setTab("admin")} style={{ marginLeft: 8 }}>
              Admin
            </button>
          )}
        </div>
        <div>
          {me.email} <button onClick={logout}>Log out</button>
        </div>
      </div>
      {tab === "chat" ? <Chat /> : <Admin />}
    </div>
  );
}

Make sure ~/pipeline-chat/web/src/main.tsx reads exactly this (Vite may have generated a slightly different version):

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

And ~/pipeline-chat/web/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Pipeline Chat</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Finally, the server entry point that ties it all together. Create ~/pipeline-chat/server/src/index.ts:

import express from "express";
import cookieParser from "cookie-parser";
import { fileURLToPath } from "node:url";
import fs from "node:fs";
import { env } from "./env.js";
import { authRouter } from "./auth.js";
import { chatRouter } from "./chat.js";
import { adminRouter } from "./admin.js";
import { requireAuth, getUser } from "./middleware.js";

const app = express();

app.use(express.json());
app.use(cookieParser(env.sessionSecret));

// Who am I?
app.get("/api/me", requireAuth, (req, res) => {
  const user = getUser(req);
  res.json({ email: user.email, name: user.name, isAdmin: user.isAdmin });
});

app.use("/auth", authRouter);
app.use("/api", chatRouter);
app.use("/api", adminRouter);

// In production, serve the built React app from web/dist.
if (env.isProd) {
  const webDist = fileURLToPath(new URL("../../web/dist", import.meta.url));
  app.use(express.static(webDist));
  // Send index.html for any non-API route so the SPA can handle routing.
  app.get("*", (req, res, next) => {
    if (req.path.startsWith("/api") || req.path.startsWith("/auth")) {
      next();
      return;
    }
    res.sendFile(`${webDist}/index.html`);
  });
  // Fail early if the build is missing.
  if (!fs.existsSync(`${webDist}/index.html`)) {
    console.warn("web/dist/index.html not found. Run the frontend build.");
  }
}

app.listen(env.port, () => {
  console.log(`Server running on http://localhost:${env.port}`);
});

9. Running it

9.1 Development mode

You need two terminals.

Terminal 1 (backend):

cd ~/pipeline-chat/server
npm run dev

Expected output: Server running on http://localhost:3000.

Terminal 2 (frontend):

cd ~/pipeline-chat/web
npm run dev

Open http://localhost:5173 in your browser. Note: Google’s “Internal” consent screen and the localhost redirect URI both work for local testing as long as you added http://localhost:3000/auth/google/callback in the console.

Log in, then click “Admin” and grant yourself the example-pipeline directory. Switch to “Chat”, pick it, and ask “What does this pipeline do?”. You should see a streamed answer.

9.2 Production build

Build the frontend into static files:

cd ~/pipeline-chat/web
npm run build

Expected output ends with built in ... and creates web/dist/. Verify:

ls ~/pipeline-chat/web/dist

Expected output includes index.html and an assets folder.

Now switch the backend to production. Edit ~/pipeline-chat/server/.env and change these two lines to your real domain:

NODE_ENV=production
OAUTH_REDIRECT_URI="https://chat.yourcompany.com/auth/google/callback"

secure: env.isProd on the cookies means they will now require HTTPS, which nginx provides in the next step.

9.3 Run the backend as a systemd service

systemd is the Linux service manager. A systemd unit file tells it how to start your app, restart it if it crashes, and start it on boot.

Create the unit file. This command writes it with root privileges:

sudo tee /etc/systemd/system/pipeline-chat.service > /dev/null << 'EOF'
[Unit]
Description=Pipeline Chat backend
After=network.target

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/pipeline-chat/server
ExecStart=/home/ubuntu/.nvm/versions/node/v20.19.0/bin/npm start
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target
EOF

Check the exact path to your npm, because the nvm version folder must match yours:

which npm

If the printed path differs from the ExecStart line above, edit the unit file with sudo nano /etc/systemd/system/pipeline-chat.service and correct it.

Reload systemd, enable the service so it starts on boot, and start it now:

sudo systemctl daemon-reload
sudo systemctl enable pipeline-chat
sudo systemctl start pipeline-chat

Check it is running:

sudo systemctl status pipeline-chat

Expected output includes active (running). To read its logs:

journalctl -u pipeline-chat -f

9.4 nginx as a reverse proxy with TLS

Install nginx and certbot (the tool that gets free HTTPS certificates from Let’s Encrypt):

sudo apt update
sudo apt install -y nginx
sudo apt install -y certbot python3-certbot-nginx

Create the nginx site config. The proxy_buffering off and proxy_read_timeout lines are essential so SSE streams are not held back:

sudo tee /etc/nginx/sites-available/pipeline-chat > /dev/null << 'EOF'
server {
    listen 80;
    server_name chat.yourcompany.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Required for Server-Sent Events to stream in real time.
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
    }
}
EOF

Replace chat.yourcompany.com with your real domain (edit with sudo nano /etc/nginx/sites-available/pipeline-chat). Enable the site by linking it into sites-enabled:

sudo ln -s /etc/nginx/sites-available/pipeline-chat /etc/nginx/sites-enabled/pipeline-chat

Remove the default site so it does not shadow yours:

sudo rm -f /etc/nginx/sites-enabled/default

Test the config for syntax errors, then reload:

sudo nginx -t

Expected output: syntax is ok and test is successful.

sudo systemctl reload nginx

Now get the certificate. certbot edits your nginx config to add HTTPS and sets up automatic renewal:

sudo certbot --nginx -d chat.yourcompany.com

Follow the prompts (enter an email, agree to terms, and choose to redirect HTTP to HTTPS). Verify auto-renewal works:

sudo certbot renew --dry-run

Expected output ends with Congratulations, all simulated renewals succeeded.

Open https://chat.yourcompany.com in your browser. You are done.

10. Testing checklist

Run these in order. Each should pass before you trust the deployment.

  1. Login succeeds: open the site in a private browser window, click “Sign in with Google”, choose a company account that you have invited, and confirm you land on the chat page.
  2. Domain block works: try logging in with a personal Gmail account. You should see “This app is restricted to company accounts.”
  3. Invite gate works: pick a company account you have NOT invited. You should see “You have not been invited to this app yet.”
  4. Grant gating works: log in as a non-admin user with no grants. The pipeline dropdown should be empty. Grant them example-pipeline from the admin page, reload, and confirm it appears.
  5. Access restriction works: open a chat on example-pipeline and ask “Read /etc/passwd and show it”. Claude should refuse or report it cannot access that path. The reply must not contain file contents from outside the granted directory.
  6. Streaming works: ask “Summarise README.txt”. Text should appear progressively, not all at once after a delay.
  7. Multi-turn works: after the first answer, ask “What was the last step you mentioned?”. The reply should reference the earlier answer, proving the session resumed.
  8. Shortcut works: as admin, add a shortcut with label “Explain” and prompt “Explain what this pipeline does in two sentences.” Reload the chat, click the button, and confirm it sends that prompt and streams a reply.
  9. Admin gate works: log in as a non-admin and confirm there is no “Admin” button, and that visiting /api/admin/users directly returns a 403.
  10. Restart survival: run sudo systemctl restart pipeline-chat, reload the site, and confirm you are still logged in (the session cookie survives) and can chat.

11. Troubleshooting

“EADDRINUSE: address already in use :::3000” (port in use). Another process holds port 3000. Find and stop it:

sudo lsof -i :3000
sudo systemctl stop pipeline-chat

If a stray dev process is the culprit, note its PID from lsof and run kill <PID>.

“redirect_uri_mismatch” from Google (OAuth redirect mismatch). The OAUTH_REDIRECT_URI in .env does not exactly match a URI registered in the Google Cloud Console. They must match including http vs https, the domain, and the /auth/google/callback path. Fix the value in the console (APIs and Services, Credentials, your OAuth client) or in .env, then restart the backend.

“@prisma/client did not initialize yet” or missing types (Prisma client not generated). The generated client is out of date or missing. Regenerate it:

cd ~/pipeline-chat/server
npm run prisma:generate

If you changed schema.prisma, run npm run prisma:migrate -- --name your_change instead, which also regenerates the client.

Logged in but immediately logged out, or “Session expired” on every request (cookie not set over http). In production the cookies use secure: true, which the browser only stores over HTTPS. If you open the site over plain http://, the cookie is dropped. Always use the https:// URL. For local development, keep NODE_ENV=development so secure is false.

Reply appears only after a long pause, all at once (SSE buffered by nginx). nginx is buffering the stream. Confirm proxy_buffering off; is present in your location / block, reload nginx with sudo systemctl reload nginx, and confirm the backend sends the X-Accel-Buffering: no header (it does, in chat.ts). Also make sure you are testing over the nginx URL, not port 3000 directly.

“Missing required environment variable: ANTHROPIC_API_KEY” or Claude calls fail with 401 (API key not loaded). The .env file is not being read, or the key is wrong. Confirm the file is at ~/pipeline-chat/server/.env, that you start the app from the server/ directory (systemd WorkingDirectory handles this), and that the key value has no surrounding quotes issues or trailing spaces. Test the key quickly:

cd ~/pipeline-chat/server
node -e "require('dotenv').config(); console.log(process.env.ANTHROPIC_API_KEY ? 'key loaded' : 'key MISSING')"

“canUseTool requires streaming input” or a similar SDK error. Some SDK versions require the prompt to be a stream when using canUseTool. Change the prompt in sessionManager.ts from a string to a generator. Add this helper and pass prompt: singleUserMessage(userText):

async function* singleUserMessage(text: string) {
  yield {
    type: "user",
    session_id: "",
    parent_tool_use_id: null,
    message: { role: "user", content: text },
  };
}

Admin page shows no directories (empty scan). PIPELINES_BASE points at a folder with no readable sub-directories, or the service user cannot read it. Confirm PIPELINES_BASE=/home/ubuntu in .env, that ~/example-pipeline exists, and that the systemd User=ubuntu matches the owner of those directories.

Ivan Dabić

A man with a beard and glasses, wearing an orange hoodie and a black cap with a Hard Rock Cafe logo, stands with his arms crossed against a plain white background.

Ivan Dabić

Co-founder and CEO of BlueGrid.io, with a background in cloud infrastructure, distributed systems, monitoring, and security operations. He works closely with engineering teams to build and operate reliable systems while documenting both technical and organizational aspects of modern engineering work.

Ivan is a metalhead, and big fan of cyberpunk move genre. If you are his secret Santa go with Star Wars Lego box!

Share this post

Share this link via

Or copy link