Processing Customer Feedback With Claude Function Calling
What You’ll Need
- n8n Cloud or self-hosted n8n
- Hetzner VPS or DigitalOcean for hosting your application and database
- Namecheap for securing an API domain name
- Anthropic API Key with access to Claude 3.5 Sonnet
- Node.js v20 LTS or higher installed on your build machine
Table of Contents
- Architecture of a Claude-Powered Feedback Pipeline
- Defining Tools and JSON Schema for Claude
- Building the Processing Loop and Routing Engine
- Production Reliability, Deduplication, and VPS Hosting
- Getting Started
Architecture of a Claude-Powered Feedback Pipeline
Processing unstructured customer feedback at scale has traditionally required a fragile mix of regex patterns, fine-tuned text classification models, and manual triage. When a user submits a multi-paragraph support ticket, app review, or survey response, they often combine multiple complaints, feature requests, and emotional expressions into a single text block.
By leveraging Claude function calling, we transform unstructured text into deterministic, schema-validated database actions and third-party API calls. Claude 3.5 Sonnet evaluates incoming feedback, determines which downstream system needs to act on it, constructs the structured JSON payload, and requests execution of specific tools.
Here is the flow of our backend pipeline:
- Webhooks receive feedback payloads from customer-facing interfaces. If you are processing real-time system events, ensure you review Handling Webhook Event Deduplication in Distributed Systems to avoid executing model evaluations on duplicate deliveries.
- The payload is pushed into an ingestion service that calls the Anthropic API, passing along custom tool definitions.
- Claude analyzes the text and returns a response containing one or more
tool_usecontent blocks. - The local execution engine runs the specified internal functions, such as logging a issue in PostgreSQL, posting an urgent churn alert to Slack, or opening a Jira ticket.
- If customer profile updates originate directly from upstream database state changes, you can combine this pipeline with Configuring PostgreSQL Change Data Capture with Debezium to stream feedback alongside account state context.
+------------------+ +-------------------+ +--------------------+
| Incoming Text | --> | Express Ingest | --> | Claude Sonnet API |
| (Webhook Payload| | API Controller | | (Evaluates Intent) |
+------------------+ +-------------------+ +--------------------+
|
v
+------------------+ +-------------------+ +--------------------+
| Dynamic Actions | <-- | Tool Execution | <-- | Structured Block |
| (DB, Jira, Slack)| | Dispatcher Loop | | (tool_use response)|
+------------------+ +-------------------+ +--------------------+
This decoupled architecture guarantees that AI reasoning remains bounded by exact validation schemas, preventing random output variations from breaking production databases.
Defining Tools and JSON Schema for Claude
Anthropic’s tool use mechanism requires explicit schema definitions for every function you expose to the model. We define exact properties, required fields, and descriptive parameter summaries.
We will create three primary tools for our engine:
create_jira_issue: Triggered when the user reports a software bug or performance degradation.escalate_churn_risk: Triggered when negative feedback indicates the customer plans to cancel their subscription.log_feature_request: Triggered when the user suggests improvements or new capabilities.
Here is the complete configuration module in TypeScript defining our tools array and PostgreSQL schema.
import { Tool } from "@anthropic-ai/sdk/resources/messages";
export const databaseSchemaSql = `
CREATE TABLE IF NOT EXISTS customer_feedback (
id SERIAL PRIMARY KEY,
customer_id VARCHAR(64) NOT NULL,
raw_feedback TEXT NOT NULL,
sentiment VARCHAR(16) NOT NULL,
action_taken VARCHAR(64) NOT NULL,
processed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS feature_requests (
id SERIAL PRIMARY KEY,
customer_id VARCHAR(64) NOT NULL,
module_name VARCHAR(64) NOT NULL,
request_details TEXT NOT NULL,
priority VARCHAR(16) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS churn_alerts (
id SERIAL PRIMARY KEY,
customer_id VARCHAR(64) NOT NULL,
urgency_level VARCHAR(16) NOT NULL,
reasoning TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`;
export const feedbackTools: Tool[] = [
{
name: "create_jira_issue",
description: "Create an engineering ticket for software bugs, system crashes, or visual glitches reported by the user.",
input_schema: {
type: "object",
properties: {
summary: {
type: "string",
description: "A concise summary of the reported bug."
},
component: {
type: "string",
description: "The affected module, such as auth, billing, UI, or API."
},
severity: {
type: "string",
enum: ["low", "medium", "high", "critical"],
description: "Calculated severity level based on impact described in feedback."
},
reproduction_steps: {
type: "string",
description: "Steps mentioned by the user to reproduce the bug."
}
},
required: ["summary", "component", "severity", "reproduction_steps"]
}
},
{
name: "escalate_churn_risk",
description: "Escalate critical account dissatisfaction, threats to switch competitors, or pricing complaints to customer success.",
input_schema: {
type: "object",
properties: {
customerId: {
type: "string",
description: "Unique identifier for the customer."
},
urgency: {
type: "string",
enum: ["medium", "high", "critical"],
description: "Urgency of intervention based on sentiment."
},
summary_reasoning: {
type: "string",
description: "Analysis of why the customer is posing a churn risk."
}
},
required: ["customerId", "urgency", "summary_reasoning"]
}
},
{
name: "log_feature_request",
description: "Log product feature requests, enhancements, or platform compatibility requests into the product backlog.",
input_schema: {
type: "object",
properties: {
customerId: {
type: "string",
description: "Unique identifier for the customer."
},
moduleName: {
type: "string",
description: "Target platform component or system requested."
},
requestDetails: {
type: "string",
description: "Detailed description of the user's requested functionality."
},
priority: {
type: "string",
enum: ["low", "medium", "high"],
description: "Estimated business value calculated from customer phrasing."
}
},
required: ["customerId", "moduleName", "requestDetails", "priority"]
}
}
];
Each tool schema acts as an interface barrier. If incoming feedback does not match any operational scenario, Claude bypasses tool activation and returns standard conversational text, which our runtime engine routes to generic persistence.
💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.
Building the Processing Loop and Routing Engine
Now we build the execution loop. We construct a complete Express service using the @anthropic-ai/sdk library and node-postgres (pg). The application handles input webhooks, manages state calls with Anthropic, parses tool_use content blocks, executes dynamic SQL or mock external API requests, and responds to the client.
To orchestrate these workflows visually, you can also connect external services using n8n Cloud to handle webhook authentication and secondary messaging channels.
Here is the complete node processing application.
import express, { Request, Response } from "express";
import Anthropic from "@anthropic-ai/sdk";
import { Pool } from "pg";
import { feedbackTools, databaseSchemaSql } from "./schemas";
const app = express();
app.use(express.json());
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY || "missing-key-check"
});
const dbPool = new Pool({
host: process.env.POSTGRES_HOST || "localhost",
port: parseInt(process.env.POSTGRES_PORT || "5432", 10),
user: process.env.POSTGRES_USER || "postgres",
password: process.env.POSTGRES_PASSWORD || "postgres",
database: process.env.POSTGRES_DB || "feedback_db"
});
async function initializeDatabase(): Promise<void> {
const client = await dbPool.connect();
try {
await client.query(databaseSchemaSql);
console.log("Database schema initialized successfully.");
} catch (error) {
console.error("Failed to initialize database schema:", error);
} finally {
client.release();
}
}
interface FeedbackPayload {
customerId: string;
feedbackText: string;
}
async function executeCreateJiraIssue(summary: string, component: string, severity: string, reproductionSteps: string): Promise<string> {
console.log(`[JIRA API] Creating issue under ${component}: ${summary} (${severity})`);
return `TICKET-${Math.floor(1000 + Math.random() * 9000)}`;
}
async function executeEscalateChurn(customerId: string, urgency: string, summaryReasoning: string): Promise<void> {
console.log(`[CHURN ESCALATION] Urgency ${urgency} for customer ${customerId}`);
const client = await dbPool.connect();
try {
await client.query(
"INSERT INTO churn_alerts (customer_id, urgency_level, reasoning) VALUES ($1, $2, $3)",
[customerId, urgency, summaryReasoning]
);
} finally {
client.release();
}
}
async function executeLogFeatureRequest(customerId: string, moduleName: string, requestDetails: string, priority: string): Promise<void> {
console.log(`[PRODUCT BACKLOG] Module: ${moduleName}, Priority: ${priority}`);
const client = await dbPool.connect();
try {
await client.query(
"INSERT INTO feature_requests (customer_id, module_name, request_details, priority) VALUES ($1, $2, $3)",
[customerId, moduleName, requestDetails, priority]
);
} finally {
client.release();
}
}
async function processFeedbackWithClaude(customerId: string, feedbackText: string): Promise<{ status: string; actionsTaken: string[] }> {
const actionsTaken: string[] = [];
const response = await anthropic.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
system: "You are an automated backend triage engine. Analyze customer feedback and execute appropriate system tool actions when actionable issues, churn risks, or feature requests occur.",
tools: feedbackTools,
messages: [
{
role: "user",
content: `Customer ID: ${customerId}\nFeedback: "${feedbackText}"`
}
]
});
if (response.stop_reason === "tool_use") {
for (const content of response.content) {
if (content.type === "tool_use") {
const toolName = content.name;
const toolArgs = content.input as Record<string, any>;
if (toolName === "create_jira_issue") {
const ticketId = await executeCreateJiraIssue(
toolArgs.summary,
toolArgs.component,
toolArgs.severity,
toolArgs.reproduction_steps
);
actionsTaken.push(`Created Jira Issue ${ticketId}`);
} else if (toolName === "escalate_churn_risk") {
await executeEscalateChurn(
customerId,
toolArgs.urgency,
toolArgs.summary_reasoning
);
actionsTaken.push(`Escalated Churn Risk (${toolArgs.urgency})`);
} else if (toolName === "log_feature_request") {
await executeLogFeatureRequest(
customerId,
toolArgs.moduleName,
toolArgs.requestDetails,
toolArgs.priority
);
actionsTaken.push(`Logged Feature Request for ${toolArgs.moduleName}`);
}
}
}
}
const sentiment = actionsTaken.some(a => a.includes("Churn")) ? "NEGATIVE" : "NEUTRAL_OR_POSITIVE";
const client = await dbPool.connect();
try {
await client.query(
"INSERT INTO customer_feedback (customer_id, raw_feedback, sentiment, action_taken) VALUES ($1, $2, $3, $4)",
[customerId, feedbackText, sentiment, actionsTaken.join(", ") || "No_Action_Required"]
);
} finally {
client.release();
}
return { status: "processed", actionsTaken };
}
app.post("/api/v1/feedback", async (req: Request, res: Response): Promise<void> => {
try {
const { customerId, feedbackText } = req.body as FeedbackPayload;
if (!customerId || !feedbackText) {
res.status(400).json({ error: "Missing required fields customerId or feedbackText" });
return;
}
const result = await processFeedbackWithClaude(customerId, feedbackText);
res.status(200).json(result);
} catch (error) {
console.error("Error processing feedback:", error);
res.status(500).json({ error: "Internal processing error" });
}
});
const PORT = parseInt(process.env.PORT || "3000", 10);
app.listen(PORT, async () => {
await initializeDatabase();
console.log(`Feedback triage engine live on port ${PORT}`);
});
When multi-topic feedback arrives, Sonnet returns multiple tool invocation calls within a single response pass, enabling bug tracking and churn logging concurrently without issuing redundant system prompts.
Production Reliability, Deduplication, and VPS Hosting
Deploying AI triage workers in production requires isolation from noisy public traffic, steady connection management, and dedicated server environments. Deploying your application on a cheap Hetzner VPS or a DigitalOcean droplet ensures predictably high IOPS, minimal compute latency, and fast database response times.
To track system analytics and monitor tool call counts, you can also connect your processing database to a unified internal portal. Read Deploying Appsmith on Budget Hetzner Cloud VPS to establish internal customer operations management dashboards.
To deploy our application using Docker containers on host instances, we use this production-ready docker-compose.yml configuration:
version: '3.8'
services:
feedback-app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- POSTGRES_HOST=postgres-db
- POSTGRES_PORT=5432
- POSTGRES_USER=app_user
- POSTGRES_PASSWORD=app_secure_password
- POSTGRES_DB=production_feedback
depends_on:
postgres-db:
condition: service_healthy
restart: always
postgres-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app_user
POSTGRES_PASSWORD: app_secure_password
POSTGRES_DB: production_feedback
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d production_feedback"]
interval: 5s
timeout: 5s
retries: 5
restart: always
volumes:
postgres_data:
To configure public endpoint mapping securely with SSL termination, configure domain routing on Namecheap pointing to your host VPS IP, and issue SSL certificates using Certbot over NGINX.
Now test your endpoint using curl from your terminal:
curl -X POST http://localhost:3000/api/v1/feedback \
-H "Content-Type: application/json" \
-d '{
"customerId": "usr_998231",
"feedbackText": "Your application crashed during my export step on the reporting module! I lost 2 hours of work. If this happens again I am canceling our enterprise agreement."
}'
The system output confirms dynamic processing:
{
"status": "processed",
"actionsTaken": [
"Created Jira Issue TICKET-4812",
"Escalated Churn Risk (high)"
]
}
By constraining Claude to execution tools using JSON schemas, you turn unpredictable text input into structured database operations that run reliably in high-volume production environments.
Getting Started
To implement this workflow in your engineering ecosystem:
- Deploy your server infrastructure using a reliable cloud provider like Hetzner VPS or DigitalOcean.
- Configure domain mapping and DNS through Namecheap.
- Connect external webhooks, API tools, and notification pipelines through n8n Cloud.
Outsource Your Automation
Don’t have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr, mention SYS3-HUGO for priority. Or DM at chasebot.online.
Want to automate this yourself?
Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.
Want this engine running on your own VPS?
This blog publishes itself — daily, unattended, on free API tiers. The full engine, Hugo theme, and setup guide are available as System 3.
Get System 3