Mistral AI Security Guardrails for Square Payment Dispute Resolution
AI agent drafts responses to Square payment disputes while guardrails prevent unauthorized actions and redact PII, using Mistral and REAA’s guardrail chain.
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 security guardrail-layered AI agent that drafts responses to Square payment disputes using Mistral AI. You’ll fetch disputes via the Square SDK, analyze evidence, generate draft responses with Mistral, and route every output through a chain of guardrails that redact PII, filter toxicity, detect prompt injection, and require human approval before submission. A monthly budget engine caps costs, and cost telemetry flows to Langfuse for observability.
Prerequisites
Node.js 22+ and pnpm 10 installed
A Mistral AI API key (sign up at console.mistral.ai)
A Square Developer account with a sandbox location, access token, and webhook signature key (dashboard.squareup.com)
A Langfuse account with public/secret keys and host URL (cloud.langfuse.com or self-hosted)
A Slack webhook URL for approval notifications (api.slack.com/messaging/webhooks)
Familiarity with TypeScript, Next.js App Router, and basic payment dispute terminology
Step 1: Scaffold the project and install dependencies
Create a new Next.js project with TypeScript and the App Router:
Expected output: A clean install with no workspace or peer-dep warnings.
Step 2: Configure environment variables
Create .env.example with placeholder values for every integration:
env
# Env vars used by mistral-ai-security-guardrails-for-square-payment-dispute-resolution.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentMISTRAL_API_KEY=<your-mistral-key>SQUARE_ACCESS_TOKEN=<your-square-access-token>SQUARE_LOCATION_ID=<your-square-location-id>SQUARE_WEBHOOK_SIGNATURE_KEY=<your-webhook-signature-key>SLACK_WEBHOOK_URL=<your-slack-webhook-url>APPROVAL_API_KEY=<your-approval-api-key>MONTHLY_BUDGET_USD=10.00LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_HOST=<your-langfuse-host>
Copy it to .env.local and fill in your real values:
terminal
cp .env.example .env.local
Expected output: A .env.local file with your API keys (never committed to git).
Step 3: Define types and config
Create src/types/index.ts — these interfaces model the dispute data flowing through the pipeline and the response the agent produces:
Now create src/config.ts to load and validate these values at startup. It uses getEnvVar and getEnvFloat from @reaatech/llm-cost-telemetry (typed env helpers), then validates the full shape with a Zod schema:
If any required env var is missing or MONTHLY_BUDGET_USD is negative, loadConfig() throws at startup — failing early before any dispute processing happens.
Expected output: TypeScript compiles without errors when you run pnpm typecheck.
Step 4: Build the Square client adapter
Create src/lib/square-client.ts. This wraps the Square SDK to fetch disputes, get evidence, and submit evidence text. Every API call is wrapped in a typed error class that preserves the HTTP status code and response body:
ts
import { SquareClient, SquareError } from "square";import type { AppConfig } from "../config.js";import type { DisputeRecord } from "../types/index.js";export class SquareClientError extends Error { public readonly statusCode: number; public readonly body: string; constructor(message: string, statusCode: number, body: string) { super(message); this.name = "SquareClientError"; this.statusCode = statusCode; this.body =
Notice the factory function pattern — createSquareClient(config) returns an object of methods. This makes it mockable in tests.
Expected output: No TypeScript errors. Square’s disputes.list() returns an async iterable, and the code consumes it with for await.
Step 5: Build the Mistral client adapter
Create src/lib/mistral-client.ts. This wraps the Mistral AI SDK with two methods — one for drafting dispute responses, one for generating evidence analysis:
ts
import { Mistral } from "@mistralai/mistralai";import * as errors from "@mistralai/mistralai/models/errors";import type { AppConfig } from "../config.js";import type { DisputeRecord } from "../types/index.js";export class MistralClientError extends Error { public readonly statusCode: number; public readonly body: string; constructor(message: string, statusCode: number, body: string) { super(message); this.name = "MistralClientError"; this.statusCode = statusCode; this.body = body; }}export function createMistralClient(config: AppConfig) { const mistral = new Mistral({ apiKey: config.mistralApiKey }); async function draftDisputeResponse(dispute: DisputeRecord, evidenceText: string): Promise<string> { try { const systemPrompt = `You are a payment dispute resolution specialist. Draft a professional response to a payment dispute.`; const context = `Dispute ID: ${dispute.id}\nReason: ${dispute.reason ?? "N/A"}\nAmount: ${String(dispute.amount ?? "N/A")} ${dispute.currency ?? ""}\nEvidence: ${evidenceText}`; const result = await mistral.chat.complete({ model: "mistral-large-latest", messages: [{ role: "user", content: systemPrompt + "\n\n" + context }], }); const content = result.choices[0]?.message?.content; if (typeof content !== "string") { return ""; } return content; } catch (error) { if (error instanceof errors.MistralError) { throw new MistralClientError(error.message, error.statusCode, error.body); } throw error; } } async function generateDisputeAnalysis(dispute: DisputeRecord): Promise<{ analysis: string; confidence: number }> { try { const prompt = `Analyze the following payment dispute and provide a confidence score (0-1) for the likelihood of winning:\nDispute ID: ${dispute.id}\nReason: ${dispute.reason ?? "N/A"}\nAmount: ${String(dispute.amount ?? "N/A")} ${dispute.currency ?? ""}\nStatus: ${dispute.statusText ?? "N/A"}`; const result = await mistral.chat.complete({ model: "mistral-medium-latest", messages: [{ role: "user", content: prompt }], }); const content = result.choices[0]?.message?.content; const analysis = typeof content === "string" ? content : ""; const confidence = 0.85; return { analysis, confidence }; } catch (error) { if (error instanceof errors.MistralError) { throw new MistralClientError(error.message, error.statusCode, error.body); } throw error; } } return { draftDisputeResponse, generateDisputeAnalysis };}
The import { Mistral } is a named import (not default). Error handling wraps errors.MistralError into your own MistralClientError that preserves statusCode and body.
Expected output: TypeScript types resolve. The mistral-large-latest model handles drafting; mistral-medium-latest handles analysis (cheaper for lighter work).
Step 6: Set up the guardrail chain (two layers)
Create src/lib/guardrail-config.ts. This configures two protection layers:
hai-guardrails engine (from Presidio) — pre-screens dispute context for prompt injection, PII, and secrets before it reaches Mistral.
REAA guardrail chain — a composable pipeline with PIIRedaction (masks PII in input), PromptInjection (detects jailbreak attempts), PIIScan (scans Mistral output for residual PII), and ToxicityFilter (filters toxic output).
Expected output: No type errors. The chain uses a 2-second latency budget, 8000 token cap, 2 retries with 200ms delay, and skips slow guardrails under pressure.
Step 7: Set up the approval workflow
Create src/lib/approval-workflow.ts. This wraps REAA’s ApprovalWorkflow with a Slack approver. When the dispute handler wants to submit evidence, it requests approval — a Slack notification goes out, and an admin approves or denies via the API:
ts
import { ApprovalWorkflow, SlackApprover } from "@reaatech/tool-use-firewall-approvals";import type { ApproverGroup } from "@reaatech/tool-use-firewall-approvals";import { createRequestContext } from "@reaatech/tool-use-firewall-core";import type { AppConfig } from "../config.js";import { createSquareClient } from "./square-client.js";export function createApprovalWorkflow(config: AppConfig) { const slackApprover = new SlackApprover({ type: "slack", webhook_url_env: "SLACK_WEBHOOK_URL", }); const approverGroups = new Map<string, ApproverGroup>(); approverGroups.set("admin", slackApprover); const workflow = new ApprovalWorkflow( { default_timeout_ms: 300000, max_pending_approvals: 1000, required_for: [ { tools: ["submit_evidence"], approvers: ["admin"], min_approvals: 1 }, ], }, approverGroups, ); async function requestApproval(disputeId: string): Promise<string> { const ctx = createRequestContext({ requestId: disputeId, sessionId: crypto.randomUUID(), method: "tools/call", toolName: "submit_evidence", }); return workflow.requestApproval(ctx); } async function checkApprovalAndSubmit( approvalId: string, ): Promise<{ submitted: boolean; status: string }> { const status = workflow.getStatus(approvalId); if (!status) { return { submitted: false, status: "not_found" }; } if (status.status === "APPROVED") { const square = createSquareClient(config); await square.submitEvidenceText(status.context.requestId, ""); return { submitted: true, status: "APPROVED" }; } return { submitted: false, status: status.status }; } async function getStatus(id: string): Promise<string> { const status = workflow.getStatus(id); if (!status) { return await Promise.resolve("not_found"); } return await Promise.resolve(status.status); } async function approve(id: string): Promise<void> { await workflow.approve(id, "admin", "admin"); } async function deny(id: string): Promise<void> { await workflow.deny(id, "admin", "admin"); } async function listPending(): Promise<unknown[]> { const result = workflow.listPending(); return await Promise.resolve(result); } return { requestApproval, checkApprovalAndSubmit, getStatus, approve, deny, listPending, };}
The required_for config declares that the tool submit_evidence needs 1 approval from the admin group. Approvals expire after 5 minutes (default_timeout_ms: 300000).
Expected output: The approval workflow is ready. The Slack webhook URL is loaded from the SLACK_WEBHOOK_URL env var at runtime.
Step 8: Implement budget control and cost telemetry
Create two service files under src/services/.
src/services/budget-service.ts — uses BudgetController from REAA with a SpendStore and BudgetScope.User scope to cap monthly spend:
ts
import { BudgetController } from "@reaatech/agent-budget-engine";import { SpendStore } from "@reaatech/agent-budget-spend-tracker";import { BudgetScope } from "@reaatech/agent-budget-types";import type { AppConfig } from "../config.js";export function createBudgetService(config: AppConfig) { const store = new SpendStore(); const controller = new BudgetController({ spendTracker: store }); controller.defineBudget({ scopeType: BudgetScope.User, scopeKey: "default", limit: config.monthlyBudget, policy: { softCap: 0.8, hardCap: 1.0, autoDowngrade: [], disableTools: [] }, }); function preFlightCheck( estimatedCost: number, modelId: string, ): { allowed: boolean; suggestedModel?: string; disabledTools?: string[] } { const result = controller.check({ scopeType: BudgetScope.User, scopeKey: "default", estimatedCost, modelId, tools: [], }); return { allowed: result.allowed, suggestedModel: result.suggestedModel, disabledTools: result.disabledTools, }; } function recordSpend( inputTokens: number, outputTokens: number, cost: number, modelId: string, ): void { controller.record({ requestId: crypto.randomUUID(), scopeType: BudgetScope.User, scopeKey: "default", cost, inputTokens, outputTokens, modelId, provider: "mistral", timestamp: new Date(), }); } return { preFlightCheck, recordSpend };}
The budget lifecycle follows: Active -> Warned (at 80% soft cap) -> Degraded -> Stopped (at 100% hard cap).
src/services/telemetry-service.ts — creates cost spans using REAA utilities and sends them to Langfuse:
Note the CostSpanSchema.parse(span) call — this validates the span shape at runtime before sending it to Langfuse, catching malformed data early.
Expected output: Both files compile cleanly. The budget engine tracks spend per user scope; the telemetry service wraps Langfuse calls behind a Zod-validated boundary.
Step 9: Wire up the dispute handler agent
Create src/agent/dispute-handler.ts. This is the main orchestration class that connects all seven services. It accepts them as constructor dependencies (interface-based, so tests can mock any layer):
The pipeline runs in order: fetch dispute -> fetch evidence -> hai-guardrails pre-screen -> input guardrail chain -> budget check -> Mistral draft -> output guardrail chain -> record spend -> telemetry -> request approval. If any guardrail or budget check fails, the handler returns a graceful rejection with draftText: "" and a descriptive analysisSummary.
Expected output: The DisputeHandler class compiles with no errors. Notice the local interfaces at the top — they decouple the handler from specific package imports, making it trivial to test.
Step 10: Create the API routes
You’ll create five API routes under app/api/.
app/api/webhook/square/route.ts — receives Square webhook notifications for dispute events, verifies the HMAC-SHA256 signature, and triggers the dispute handler:
The vitest config at vitest.config.ts enforces 90% coverage across lines, branches, functions, and statements. Run the full verification suite:
terminal
pnpm typecheckpnpm lintpnpm test
Expected output:pnpm typecheck exits 0, pnpm lint exits 0, and vitest reports zero failed tests. Coverage thresholds are set at 90%+ on all runtime code.
Next steps
Add Discord approval notifications — DiscordApprover from @reaatech/tool-use-firewall-approvals works identically to SlackApprover but posts to Discord channels instead.
Auto-downgrade models on budget pressure — Configure autoDowngrade in the BudgetController.defineBudget policy to switch from mistral-large-latest to mistral-medium-latest when spend exceeds 80%.
Add rate limiting — Wrap the webhook route with a token-bucket rate limiter to prevent abuse from repeated Square notifications.
Extend guardrails — Add topic enforcement (TopicFilter from @reaatech/guardrail-chain-guardrails) to restrict responses to payment-dispute topics only.