A few weeks ago we wrote about why maintenance requests keep falling through the cracks at property management companies — and how AI triage can fix it. That post was aimed at operators. This one is for the developers who'd actually build the thing.
Most "agentic workflow" tutorials online are really Zapier tutorials in disguise. Drag a node, connect it to ChatGPT, done. Those tools are fine for prototypes, but they're wrong for production systems where you care about cost at scale, custom logic, data ownership, and not being locked into someone else's pricing cliff.
This is the from-scratch version. We'll build a maintenance triage agent in TypeScript using the Claude API's tool use, wire up email and SMS intake, persist to a database, and ship it.
What We're Building
A small service that:
- Receives a maintenance request by email or SMS
- Parses unit number, issue description, and contact info
- Classifies the request (category, urgency) with an LLM
- Routes to the right vendor or escalates to a human
- Sends an instant acknowledgment back to the tenant
- Writes a ticket record to the database
Six steps, ~300 lines of code, one deploy.
Stack Choices (and Why)
Language: Node/TypeScript vs Python
Both work. The Claude SDK is first-class in both. The tradeoff:
- Python has stronger ecosystem parity with the rest of the ML/AI world. If you're already running LangGraph or CrewAI or LlamaIndex, stay there.
- TypeScript has better ergonomics for the rest of the job — web framework, webhook handlers, database client, deployment. If the LLM is one step in a mostly-web-service app (which this is), TS keeps the whole thing in one language.
We're using TypeScript here because triage is 80% plumbing and 20% LLM. If this post were about agent orchestration with RAG and vector search, Python would win.
Model: Claude vs GPT vs Gemini
For structured classification with tool use:
- Claude (Sonnet 4.6) — best-in-class tool use reliability in our testing. Tool call arguments match the schema almost every time. Priced at the premium end of the flagship tier. This is what we use for the main loop.
- OpenAI (GPT-5 / o-series) — comparable reasoning quality. OpenAI's
strict: truemode locks schema conformance tightly; the tradeoff in our testing is occasional hesitation on complex nested tool chains. - Gemini (2.x family) — the cheapest of the three by a wide margin, improving fast, but tool-use determinism on multi-tool agent loops still trails Claude for our workloads.
Sonnet vs Haiku
Worth a specific callout since this is a common optimization. Triage is a short, highly structured workload — small inputs, 2–3 tool calls, short outputs. That's exactly where Claude Haiku 4.5 shines: it's roughly 3x cheaper than Sonnet, noticeably faster, and for this kind of classify-and-route work the quality gap is small in our experience.
Our recommendation: build on Sonnet 4.6 first to establish a quality ceiling. Once the prompt, tools, and edge cases are stable, run a week of real tickets through Haiku and compare the classifications and routing decisions side by side. If they match, switch — the only code change is the model string. If they don't, the ones that diverge usually reveal where your system prompt needs to be more explicit.
For a triage agent where a wrong tool call means a plumbing emergency gets routed to the cleaning vendor, determinism matters more than raw reasoning. Claude wins.
Email and SMS
- Email in/out: Resend. Clean API, inbound webhooks, solid deliverability.
- SMS in/out: Telnyx. Programmable messaging with a developer experience that doesn't make you want to throw your laptop. Twilio works too if you prefer.
Storage
Any Postgres or Mongo will do — Railway Postgres, Neon, Supabase, Mongo Atlas, whatever fits. The schema is trivial (one tickets table), so don't overthink this. We'll show the code with a pseudo-client; swap in your ORM of choice.
Deploy
Railway. Long-running Node service, one webhook endpoint, managed database in the same project. The whole thing is a single deploy.
The Architecture
Tenant email/SMS
│
▼
Inbound webhook ──► Parser ──► Agent loop ──► Tool executor
(Resend/Telnyx) (Claude API) │
├─► DB (ticket write)
├─► Vendor notify (email)
└─► Tenant ack (email/SMS)Nothing exotic. The interesting part is the agent loop.
The Agent Loop
The core of any agentic workflow is a loop: model decides, tools execute, results feed back into the model, repeat until the model says "done." The Anthropic TypeScript SDK makes this explicit.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const tools: Anthropic.Tool[] = [
{
name: "classify_request",
description: "Classify the maintenance request's category and urgency.",
input_schema: {
type: "object",
properties: {
category: {
type: "string",
enum: ["plumbing", "electrical", "hvac", "appliance", "cosmetic", "other"],
},
urgency: {
type: "string",
enum: ["emergency", "urgent", "routine"],
description: "emergency = same-day, urgent = 48h, routine = scheduled",
},
summary: { type: "string", description: "One-line summary for the dispatcher." },
},
required: ["category", "urgency", "summary"],
},
},
{
name: "route_to_vendor",
description: "Dispatch the ticket to the appropriate vendor for this category.",
input_schema: {
type: "object",
properties: {
vendor_category: { type: "string" },
},
required: ["vendor_category"],
},
},
{
name: "notify_tenant",
description: "Send an acknowledgment message to the tenant.",
input_schema: {
type: "object",
properties: {
channel: { type: "string", enum: ["email", "sms"] },
message: { type: "string" },
},
required: ["channel", "message"],
},
},
{
name: "escalate_to_human",
description: "Flag the ticket for human review when the request is ambiguous or outside normal categories.",
input_schema: {
type: "object",
properties: {
reason: { type: "string" },
},
required: ["reason"],
},
},
];
type ClassifyInput = {
category: "plumbing" | "electrical" | "hvac" | "appliance" | "cosmetic" | "other";
urgency: "emergency" | "urgent" | "routine";
summary: string;
};
type RouteInput = { vendor_category: string };
type NotifyInput = { channel: "email" | "sms"; message: string };
type EscalateInput = { reason: string };Four tools, enough to cover the real decision surface. You can start smaller (just classify_request and escalate_to_human) and add more as you see edge cases.
Note that the tool inputs don't include a ticket_id — the executor already knows which ticket it's working on via its closure/context. Only ask the model for data it actually has to reason about.
The System Prompt
The system prompt sets the agent's role and operating rules. Keep it short and concrete — long prompts don't make agents smarter, they make them slower.
const SYSTEM_PROMPT = `
You are a maintenance triage agent for a property management company.
For every request:
1. Call classify_request with the category, urgency, and a one-line summary.
2. Call route_to_vendor with the appropriate vendor category.
3. Call notify_tenant with a brief, professional acknowledgment that includes the urgency classification.
4. If the request is ambiguous, involves multiple unrelated issues, or doesn't fit a normal category, call escalate_to_human instead of route_to_vendor.
Urgency rules:
- emergency: anything involving active water damage, gas, fire, no heat in winter, no AC above 90°F, lockouts, break-ins
- urgent: broken appliances affecting daily life, partial utility loss, pest issues
- routine: cosmetic issues, minor repairs, scheduled maintenance
`.trim();The Loop
This is the full loop. Read it once, then we'll walk through it.
async function runTriageAgent(request: {
ticketId: string;
tenant: { name: string; email?: string; phone?: string };
unit: string;
body: string;
}) {
const messages: Anthropic.MessageParam[] = [
{
role: "user",
content: `Ticket ${request.ticketId}
Unit: ${request.unit}
Tenant: ${request.tenant.name} (${request.tenant.email ?? request.tenant.phone})
Request: ${request.body}`,
},
];
const MAX_ITERATIONS = 10;
for (let i = 0; i < MAX_ITERATIONS; i++) {
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
system: SYSTEM_PROMPT,
tools,
messages,
});
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason === "end_turn") return;
if (response.stop_reason === "tool_use") {
const toolUses = response.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
);
const toolResults: Anthropic.ToolResultBlockParam[] = await Promise.all(
toolUses.map(async (tu) => ({
type: "tool_result",
tool_use_id: tu.id,
content: await executeTool(tu.name, tu.input, request),
}))
);
messages.push({ role: "user", content: toolResults });
continue;
}
throw new Error(`Unexpected stop_reason: ${response.stop_reason}`);
}
throw new Error(`Agent exceeded ${MAX_ITERATIONS} iterations without completing.`);
}The pattern is simple:
- Send the request to Claude with the tool list.
- If the response is
end_turn, the agent is done. - If the response is
tool_use, execute every tool call, append the results as ausermessage, and loop.
A well-scoped triage normally finishes in 2–3 iterations. The MAX_ITERATIONS cap is a safety net — if the agent is still looping after 10 turns, something's wrong with the prompt or tools, and you want a loud failure rather than a runaway API bill.
Executing the Tools
executeTool is where your business logic lives. This is deliberately boring — the LLM does the judgment, your code does the side effects.
async function executeTool(
name: string,
input: unknown,
ctx: { ticketId: string; tenant: { email?: string; phone?: string } }
): Promise<string> {
switch (name) {
case "classify_request": {
const { category, urgency, summary } = input as ClassifyInput;
await db.tickets.update(ctx.ticketId, { category, urgency, summary });
return `Classified as ${category}/${urgency}.`;
}
case "route_to_vendor": {
const { vendor_category } = input as RouteInput;
const vendor = await db.vendors.findByCategory(vendor_category);
if (!vendor) return `No vendor found for ${vendor_category}. Escalate instead.`;
await sendVendorDispatch(vendor, ctx.ticketId);
await db.tickets.update(ctx.ticketId, { vendorId: vendor.id, status: "dispatched" });
return `Dispatched to ${vendor.name}.`;
}
case "notify_tenant": {
const { channel, message } = input as NotifyInput;
if (channel === "email" && ctx.tenant.email) {
await resend.emails.send({
from: "maintenance@yourpm.com",
to: ctx.tenant.email,
subject: `Re: Maintenance Request ${ctx.ticketId}`,
text: message,
});
} else if (channel === "sms" && ctx.tenant.phone) {
await telnyx.messages.send({
from: process.env.TELNYX_FROM!,
to: ctx.tenant.phone,
text: message,
});
}
return `Notified tenant via ${channel}.`;
}
case "escalate_to_human": {
const { reason } = input as EscalateInput;
await db.tickets.update(ctx.ticketId, { status: "escalated", escalationReason: reason });
await notifyOnCall(ctx.ticketId, reason);
return `Escalated.`;
}
default:
return `Unknown tool: ${name}`;
}
}Notice that tool results are plain strings the model reads. Be informative. "No vendor found for plumbing. Escalate instead." is better than "error" — the model can recover from the first and not the second.
Intake: Receiving the Request
Two inbound channels, one entry point.
import express from "express";
const app = express();
app.use(express.json());
app.post("/webhooks/resend-inbound", async (req, res) => {
if (req.body.type !== "email.received") return res.sendStatus(200);
res.sendStatus(200);
const { email_id, from, subject } = req.body.data;
// Resend's inbound webhook carries metadata only — fetch the body separately.
// https://resend.com/docs/dashboard/receiving/get-email-content
const { data: email } = await resend.emails.receiving.get(email_id);
const text = email?.text ?? "";
const unit = extractUnit(`${subject} ${text}`);
const ticket = await db.tickets.create({
source: "email",
tenantEmail: from,
unit,
body: text,
status: "new",
});
runTriageAgent({
ticketId: ticket.id,
tenant: { name: from, email: from },
unit,
body: text,
}).catch(console.error);
});
app.post("/webhooks/telnyx-sms", async (req, res) => {
res.sendStatus(200);
if (req.body.data?.event_type !== "message.received") return;
const { from, text } = req.body.data.payload;
const ticket = await db.tickets.create({
source: "sms",
tenantPhone: from.phone_number,
body: text,
status: "new",
});
runTriageAgent({
ticketId: ticket.id,
tenant: { name: from.phone_number, phone: from.phone_number },
unit: "unknown",
body: text,
}).catch(console.error);
});
app.listen(process.env.PORT ?? 3000);Two things worth calling out:
- Ack the webhook immediately. Both Resend and Telnyx retry on timeout (Telnyx's window is ~2 seconds). Send
200before running the agent, not after. - Fire-and-forget needs a long-running server. On Railway this works — the Node process keeps running after the response goes out. On serverless (Vercel Functions, AWS Lambda), the process is killed once the response is sent, so you'd need a queue like SQS or Upstash QStash instead.
extractUnit is a helper you write against your own data (unit numbers in subject lines, signature blocks, etc.). For SMS where the unit isn't obvious, the agent can call escalate_to_human automatically via the system prompt's ambiguity rule. Or add a request_clarification tool that sends "what unit are you in?" back to the tenant.
Why Build This Instead of Using n8n
A question I get often: "n8n has an AI node now — why not just use that?"
Three reasons to go from scratch for production systems:
- Cost structure. n8n Cloud's paid plans are priced in execution-quota tiers (€24/mo for 2,500 executions, €60/mo for 10,000, then jumping to €800/mo for 40,000). Each triage with retries or multi-step tool calls burns through multiple executions, so scaling properties pushes you up the tier ladder in step changes. Railway's Hobby plan starts at $5/mo (subscription + $5 usage credit) and scales linearly by compute/memory from there, which tends to be smoother for low-to-moderate volume.
- Debuggability. When the agent makes a bad call, you want to see the full message history, every tool call, every response. That's easy in your own logs and painful in a visual node editor.
- Portability. This codebase runs anywhere Node runs. No vendor can deprecate a node you depend on or change pricing out from under you.
For prototypes and one-off automations, no-code is fine. For a system you're going to support for years at a PM company's scale, code wins.
Deploying
Railway handles this cleanly. Push to a GitHub repo, connect the repo in Railway, add a Postgres plugin, set environment variables (ANTHROPIC_API_KEY, RESEND_API_KEY, TELNYX_API_KEY, TELNYX_FROM, DATABASE_URL), and deploy. Point your Resend inbound domain and Telnyx messaging profile at the Railway URL and you're live.
Where to Go From Here
This covers the core loop. Real production deployments add:
- Retry and idempotency for webhook deliveries (de-dupe on provider message ID).
- Rate limiting on the agent loop — don't let a bad actor spam requests and run up your API bill.
- Human-in-the-loop review for the first 50-100 classifications to calibrate urgency thresholds.
- Feedback capture — track which vendor dispatches were accepted vs reassigned, feed that back into the system prompt or a fine-tuned classifier over time.
- Phone intake via speech-to-text before the agent loop if tenants call in.
Start with the 300-line version and add complexity only when the data tells you to.
Bottom Line
Agentic workflows aren't magic and they're not (inherently) no-code. Strip away the marketing, and an agent is a loop: model picks a tool, code runs it, result feeds back in. Writing it yourself gives you control over cost, debuggability, and long-term portability that visual tools can't match.
The Claude tool use API makes the loop small enough to fit in one file. Resend and Telnyx make intake and notifications a few lines each. Railway makes deploying a service with a database a zero-config operation.
If you've been looking at n8n or Zapier for a production AI workflow and felt uneasy about building a business on it — this is the alternative. Not more complicated, just more durable.
Need this built for your property management company? We build custom maintenance triage agents and other agentic workflows for small and mid-sized businesses.