E‑commerce support teams use Perplexity to generate reply drafts from chat transcripts that often contain credit card numbers, addresses, and phone numbers. Sending raw PII to a third‑party AI risks compliance violations and erodes customer trust.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
In this tutorial you’ll build a PII shield that sits between an e-commerce support chat and the Perplexity API. When a support agent pastes a customer chat transcript into your endpoint, the system automatically strips credit card numbers, email addresses, phone numbers, SSNs, and street addresses before sending the sanitized text to Perplexity for AI draft generation. Every redaction is logged for compliance auditing, and a prompt injection guardrail blocks jailbreak attempts. You’ll wire up five packages from the @reaatech/guardrail-chain ecosystem to assemble a configurable guardrail pipeline, then expose it as a single Express POST endpoint.
Prerequisites
Node.js 22+ and pnpm 10 installed on your machine
A Perplexity API key (you can get one at perplexity.ai)
Familiarity with TypeScript and Express basics
Step 1: Scaffold the project and install dependencies
Create a new directory and write the package.json. The project runs an Express API server inside a Next.js project shell, giving you both a React frontend scaffold and Vitest for testing with ESLint for linting.
Expected output: pnpm resolves and links all packages, including the @reaatech/guardrail-chain-* ecosystem. You’ll see a node_modules/ directory and a pnpm-lock.yaml.
Step 2: Create the configuration module and env file
The config module reads environment variables, validates them with Zod, and integrates with @reaatech/guardrail-chain-config for budget validation. Create src/config.ts:
ts
import { loadConfig, validateConfig, validateConfigSafe } from "@reaatech/guardrail-chain-config";import { z } from "zod";const configSchema = z.object({ PERPLEXITY_API_KEY: z.string(), PORT: z.coerce.number().default(3000), GUARDRAIL_BUDGET_LATENCY_MS: z.coerce.number().default(2000), GUARDRAIL_BUDGET_TOKENS: z.coerce.number().default(8000),});export type AppConfig = z.infer<typeof configSchema>;export async function initConfig(): Promise<AppConfig> { const loaded = await loadConfig().catch(() => undefined); if (loaded) { validateConfig(loaded); } return configSchema.parse(process.env);}export function validateChainBudget(budget: { maxLatencyMs: number; maxTokens: number }) { const result = validateConfigSafe({ budget: { maxLatencyMs: budget.maxLatencyMs, maxTokens: budget.maxTokens, }, }); return result.config;}
initConfig() calls loadConfig() from guardrail-chain-config to load any config file or environment overrides, then validates the final result against a Zod schema. z.coerce.number() means string env vars like "3000" are automatically parsed to numbers.
Create .env.example (keep it checked in — never commit real values):
env
# Env vars used by perplexity-pii-shield-for-smb-e-commerce-support-chat.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentPERPLEXITY_API_KEY=<your-perplexity-api-key>PORT=3000GUARDRAIL_BUDGET_LATENCY_MS=2000GUARDRAIL_BUDGET_TOKENS=8000
Expected output: The files src/config.ts and .env.example are created. You’ll set PERPLEXITY_API_KEY to your real key when running the server.
Step 3: Build PII detection patterns with Luhn validation
The core of the PII shield is a set of regex patterns that detect sensitive data. Create src/services/pii-patterns.ts:
Luhn check on credit cards: A card number like 4532015112830366 passes the Luhn algorithm and gets redacted. An invalid checksum like 4532015112830367 is left alone — no false positives on random numbers that happen to match the digit count pattern.
Overlap tracking: The claimed array prevents double-redacting text that could match multiple patterns.
Sequential counters: Each redacted value gets a unique placeholder like [REDACTED_EMAIL_1] so you can correlate redactions across the pipeline.
Expected output: Two pattern arrays (CREDIT_CARD_PATTERNS with 4 entries and CONTACT_PII_PATTERNS with 4 entries) and the replacePii() function that transforms input text.
Step 4: Create the custom PII scrubber guardrail
Now you’ll wrap the regex patterns into a class that implements the Guardrail interface from @reaatech/guardrail-chain. Create src/middleware/pii-guard.ts:
ts
import { type Guardrail, type GuardrailResult, type ChainContext, ChainBuilder, type GuardrailChain } from '@reaatech/guardrail-chain';import { CREDIT_CARD_PATTERNS, CONTACT_PII_PATTERNS, replacePii } from '../services/pii-patterns.js';import { InjectionGuardrail } from './injection-guard.js';import { PIIRedaction } from '@reaatech/guardrail-chain-guardrails';export class PiiScrubberGuardrail implements Guardrail<string, string> { readonly id = "pii-scrubber"; readonly name = "PII Scrubber"; readonly type = "input" as const; readonly enabled = true; execute(input: string, _context: ChainContext): Promise<GuardrailResult<string>> { void _context; const patterns = [...CONTACT_PII_PATTERNS, ...CREDIT_CARD_PATTERNS]; const result = replacePii(input, patterns); return Promise.resolve({ passed: true, output: result.sanitized, metadata: { duration: 0, redactions: result.redactions } }); }}export function buildGuardrailChain(budget: { maxLatencyMs: number; maxTokens: number }): GuardrailChain { const chain = new ChainBuilder() .withBudget({ maxLatencyMs: budget.maxLatencyMs, maxTokens: budget.maxTokens }) .withGuardrail(new PiiScrubberGuardrail()) .withGuardrail(new PIIRedaction({ redactionStrategy: 'mask' })) .withGuardrail(new InjectionGuardrail()) .withSlowGuardrailSkipping(true) .withErrorHandling({ maxRetries: 2, retryDelayMs: 200 }) .build(); return chain;}
buildGuardrailChain() uses the fluent ChainBuilder API to compose three guardrails in order:
PiiScrubberGuardrail — your custom regex scrubber that replaces PII with [REDACTED_*] placeholders
PIIRedaction — the built-in guardrail from @reaatech/guardrail-chain-guardrails configured with mask strategy (a second layer of defense)
InjectionGuardrail — detects prompt injection attempts (you’ll build this next)
The chain also sets a latency/token budget and enables slow-guardrail skipping under pressure, plus retry logic for transient failures.
Expected output: A PiiScrubberGuardrail class and a buildGuardrailChain() factory that returns a fully wired GuardrailChain.
Step 5: Build the prompt injection guardrail
The injection guardrail wraps the built-in PromptInjection detector and enriches its output with attack taxonomy metadata from @reaatech/pi-bench-core. Create src/middleware/injection-guard.ts:
ts
import { type Guardrail, type GuardrailResult, type ChainContext } from "@reaatech/guardrail-chain";import { PromptInjection } from "@reaatech/guardrail-chain-guardrails";import { type AttackCategory, getCategoryWeight } from "@reaatech/pi-bench-core";function isValidCategory(v: string): v is AttackCategory { return ["direct-injection", "prompt-leaking", "role-playing", "encoding-attacks", "multi-turn-jailbreaks", "payload-splitting", "translation-attacks", "context-stuffing"].includes(v);}export class InjectionGuardrail implements Guardrail<string, string> { readonly id = "injection-guard"; readonly name = "Injection Guard"; readonly type = "input" as const; enabled = true; private detector: PromptInjection; constructor() { this.detector = new PromptInjection(); } async execute(input: string, context: ChainContext): Promise<GuardrailResult<string>> { const result = await this.detector.execute(input, context); const activeCategory: AttackCategory = "direct-injection"; if (!isValidCategory(activeCategory)) { throw new Error("Invalid attack category"); } if (!result.passed && result.confidence && result.confidence > 0.8) { return { passed: false, confidence: 0.95, error: new Error("Injection detected"), metadata: { duration: 0, attackCategory: activeCategory, categoryWeight: getCategoryWeight(activeCategory), }, }; } return { passed: true, confidence: 1 - (result.confidence ?? 0), metadata: { duration: 0 }, }; }}
The logic is:
High-confidence detections (confidence > 0.8) are blocked with passed: false and a fixed confidence of 0.95
The blocked result carries attackCategory and categoryWeight from pi-bench-core’s taxonomy, which is useful for compliance dashboards
Low-confidence detections are allowed through but with a reduced confidence score
Expected output: An InjectionGuardrail class that delegates to PromptInjection and applies pi-bench-core taxonomy.
Step 6: Wrap the Perplexity API client
Create a typed wrapper around the Perplexity SDK so the rest of your code doesn’t depend on the SDK’s raw shapes. Create src/services/perplexity-client.ts:
The SDK’s constructor takes a config object with apiKey, and .client() returns a DefaultApi instance. The sendChat method extracts the reply text and token counts from the SDK response shape, wrapping any network failures in a typed PerplexityError.
Expected output: A PerplexityClient class that abstracts the SDK and returns clean { reply, usage } objects.
Step 7: Build the prompt enricher and redaction logger
Two supporting services tie the pipeline together.
Prompt enricher (src/services/prompt-enricher.ts): After PII is redacted, this module appends a safe-field schema block that tells the Perplexity model which data categories it can reference.
ts
export const SAFE_FIELDS = ["orderId", "productName", "issueDescription", "category", "supportTier"] as const;export function buildSafeSchemaBlock(fields: readonly string[]): string { const header = "## Safe Fields (you may request these)"; const separator = "|-------|-------------|"; const rows = fields.map((f) => `| ${f} | Allowed |`); return [header, "| Field | Description |", separator, ...rows].join("\n");}export function enrichPrompt(chatTranscript: string, redactionPlaceholders: string[]): string { const schemaBlock = buildSafeSchemaBlock(SAFE_FIELDS); let instructions = `${chatTranscript}\n\n---\n${schemaBlock}`; if (redactionPlaceholders.length > 0) { instructions += `\n\nRedacted data references: ${redactionPlaceholders.join(", ")}`; } instructions += "\n\nIMPORTANT: Never request unredacted PII. Always refer to redacted data by their placeholders."; return instructions;}
Redaction logger (src/services/redaction-logger.ts): Logs every redaction for compliance audit trails using the global logger and metrics from @reaatech/guardrail-chain.
Expected output: Three modules — prompt-enricher.ts, redaction-logger.ts, and observability-setup.ts — that handle prompt enrichment, audit logging, and observability initialization.
Step 8: Wire the chat pipeline orchestrator
The ChatPipeline is the central orchestrator that runs the guardrail chain, enriches the prompt, calls Perplexity, and logs results. Create src/services/chat-pipeline.ts:
Run the guardrail chain against the raw transcript
If the chain rejects the input (injection detected), throw a GuardrailRejectionError
Extract redaction metadata and the sanitized output
Enrich the prompt with safe-field schema and redaction placeholders
Send the enriched text to Perplexity via sendChat()
Log the redaction summary and emit a metric
Return the AI reply, redaction summary, and token usage
Expected output: A ChatPipeline class with a processChat() method that ties all the services together.
Step 9: Create the Express route handler and app factory
The HTTP layer exposes two endpoints: the chat completion route and a health check.
Route handler (src/api/chat-completion.ts):
ts
import { Router, type Request, type Response, type NextFunction } from 'express';import { z } from 'zod';import { ChatPipeline, GuardrailRejectionError } from '../services/chat-pipeline.js';const requestSchema = z.object({ transcript: z.string().min(1, "Transcript is required") });export function createChatRouter(pipeline: ChatPipeline): Router { const router = Router(); router.post("/", async (req: Request, res: Response, next: NextFunction) => { try { const { transcript } = requestSchema.parse(req.body); const result = await pipeline.processChat(transcript); res.status(200).json(result); } catch (err) { if (err instanceof z.ZodError) { res.status(400).json({ error: "Invalid input", details: err.issues }); } else if (err instanceof GuardrailRejectionError) { res.status(422).json({ error: "Content rejected by guardrail", details: err.message }); } else { next(err); } } }); router.use((err: Error, req: Request, res: Response, next: NextFunction): void => { void req; res.status(500).json({ error: "Internal server error", details: err.message }); void next; }); return router;}
App factory (src/app.ts): Assembles the full Express application from the individual components.
ts
import express, { type Request, type Response } from 'express';import { createChatRouter } from './api/chat-completion.js';import { setupObservability } from './middleware/observability-setup.js';import { buildGuardrailChain } from './middleware/pii-guard.js';import { PerplexityClient } from './services/perplexity-client.js';import { RedactionLogger } from './services/redaction-logger.js';import { ChatPipeline } from './services/chat-pipeline.js';import { type AppConfig, validateChainBudget } from './config.js';export function createApp(config: AppConfig) { setupObservability(); const budget = { maxLatencyMs: config.GUARDRAIL_BUDGET_LATENCY_MS, maxTokens: config.GUARDRAIL_BUDGET_TOKENS }; validateChainBudget(budget); const perplexity: PerplexityClient = new PerplexityClient(config.PERPLEXITY_API_KEY); const logger = new RedactionLogger(); const pipeline = new ChatPipeline(buildGuardrailChain(budget), perplexity, logger); const app = express(); app.use(express.json()); app.use("/api/chat/completion", createChatRouter(pipeline)); app.get("/api/health", (_req: Request, res: Response) => { res.status(200).json({ status: "ok" }); }); return app;}
Entry point (src/index.ts): Starts the Express server.
ts
import { createApp } from './app.js';import { initConfig } from './config.js';const config = await initConfig();const app = createApp(config);app.listen(config.PORT, () => { console.log(`Server running on port ${String(config.PORT)}`);});process.on("unhandledRejection", (reason) => { console.error("Unhandled rejection:", reason); process.exit(1);});
Expected output: Three files — src/api/chat-completion.ts, src/app.ts, and src/index.ts. The entry point uses top-level await (Node 22+ with ESM) to load config, wire all dependencies, and start listening.
Step 10: Try the recipe
Copy the env file, set your Perplexity API key, and start the Express server:
terminal
cp .env.example .env# Edit .env to set PERPLEXITY_API_KEY to your real key# Load env vars and start the server:export $(grep -v '^#' .env | xargs) && npx tsx src/index.ts
In another terminal, send a chat transcript:
terminal
curl -X POST http://localhost:3000/api/chat/completion \ -H "Content-Type: application/json" \ -d '{"transcript":"Customer says: my email is sarah@example.com and my order #12345 has not arrived. His phone is 555-0100."}'
Route handlers are tested with a real HTTP server (http.createServer(app)) listening on a random port — no supertest dependency needed
External SDKs are mocked with vi.mock() — no live Perplexity API calls in tests
Guardrail chain tests mock the internal replacePii and PromptInjection to test each guardrail in isolation
Next steps
Add more PII categories: Extend CONTACT_PII_PATTERNS with patterns for passport numbers, driver’s license IDs, or bank account numbers. Add the regex patterns and the system redacts them automatically.
Connect a real metrics backend: Replace the console.debug-based metrics collector in observability-setup.ts with a real Prometheus or Datadog adapter. The setMetrics() function accepts any object matching the MetricsCollector interface.
Add rate limiting: Add a RateLimiter guardrail from @reaatech/guardrail-chain-guardrails to the chain builder — it supports sliding-window rate limiting keyed by user ID or session ID.