Matin Labkhandagh

Case study

AiMatin: running a business through a custom MCP server

AiMatin is the Persian AI-education academy I founded and run alone. I built a FastAPI and PostgreSQL backend behind a Next.js front end, then wrapped the admin API in a custom Node.js MCP server so an AI operator can run coupons, popup funnels, SMS automations and reports. It draws about 4,000 monthly organic unique visitors, roughly 90% from Google, and its popup funnels convert about 10% of visitors to leads.

Matin LabkhandaghProduction AI & Agentic Systems EngineerPublished · 11 min read
Monthly organic unique visitors
~4,000
Organic traffic from Google
~90%
Visitor-to-lead conversion, popup funnels
~10%
Role
Built and operate the platform and its MCP operator (solo engineer)

AiMatin is the Persian AI-education academy I founded in 2025 and run alone. It sells a prompt-engineering course and related products to a Persian-speaking audience, and every part of the business that is not teaching had to run without a team. I built a FastAPI and PostgreSQL backend behind a static Next.js front end, then wrapped the admin API in a custom Node.js MCP server so an AI operator can create coupons, configure popup funnels, edit SMS automations and read reports from a chat window. The site draws about 4,000 monthly organic unique visitors, roughly 90% of them from Google, and its popup funnels convert about 10% of visitors to leads. This page covers the architecture, the decisions behind it, the failure modes I designed against, and what I would not claim.

What problem did AiMatin have to solve?

The platform has the shape of a small e-commerce business: products, orders, a payment gateway, leads, customers, follow-up messages, promotions. In a company those are four or five roles. Here they were one person who also writes the course. The bottleneck was never compute or traffic; it was my attention. A weekly promotion meant opening an admin panel, creating a coupon, writing a popup, wiring a follow-up SMS sequence, and then coming back a few days later to read the numbers and adjust. Each step was small. Together they consumed the hours that should have gone into content.

So the problem statement I wrote for myself was narrow: marketing, reporting, CRM-related workflows and operations must be executable by an AI operator, with me reviewing rather than clicking. A dashboard shows numbers. It does not act. I needed something that could act, under constraints I controlled, against the same API the dashboard already used. That is the kind of work I now offer as AI automation engineering: not a chatbot on top of a business, but an operator with typed, bounded access to its systems.

What were the context and constraints?

Solo operator. There is no on-call rotation and no second pair of eyes on a change. Anything the AI operator can do wrong, I will discover after the fact. That pushed the design toward reversible actions and toward reports that make a bad change visible quickly.

Persian market. The public site is right-to-left Persian. The channels that reach customers are SMS, Telegram and Bale, not email. Payment runs through a domestic gateway inside FastAPI, and that logic is deliberately never reimplemented anywhere else, including in the MCP layer.

Self-hosted, no CDN dependency. Public CDNs are not reliably reachable for part of the audience, so fonts, scripts and images are served from the site itself. The front end is static or statically rendered Next.js, and the backend is a single FastAPI service in front of PostgreSQL. Every automation and every tool has to fit inside that footprint; adding a message queue or a second database for the operator layer was not on the table.

Static front end plus FastAPI. Marketing surfaces such as popups are rendered on a static site, so their configuration has to come from the database at runtime. This constraint turned out to be an advantage: it meant the operator could change what visitors see without a deploy.

How is AiMatin architected?

The flow is linear. Visitors hit the static Next.js site. The site calls FastAPI for anything dynamic: popups, lead capture, checkout, membership. FastAPI reads and writes PostgreSQL. A Node.js MCP server sits beside the backend as a pure client of the admin API and exposes it as tools. An AI operator, running in an MCP-capable client, calls those tools. Outbound messages leave through SMS, Telegram and Bale integrations.

AiMatin architecture: Next.js static site, FastAPI, PostgreSQL, Node.js MCP server, AI-operated admin, and SMS, Telegram and Bale channelsNext.js static sitevisitors, popups, lead formsFastAPIpublic API + admin APIPostgreSQLorders, leads, rules, popupsNode.js MCP servertools over the admin APIAI-operated adminMCP client + human reviewAutomation enginescheduled, DB-driven stepsSMS / Telegram / Baleoutbound customer channels

Two things in this diagram matter more than the boxes. First, the MCP server has no database access of its own. It speaks only to the admin API, the same endpoints the human-facing panel uses, so authorization and validation live in one place. Second, the automation engine is a scheduled process that reads rules from PostgreSQL, which is why the operator can create or pause a sequence and see it take effect on the next tick without a deploy. FastAPI itself generates OpenAPI and JSON Schema for every endpoint and validates request bodies through type hints, as described in the FastAPI documentation, which made the admin surface a clean thing to wrap.

Which design decisions mattered most?

Exposing the admin API as MCP tools

The central decision was to give the AI operator the same interface I had, no more and no less. I wrote a Node.js MCP server that maps admin endpoints to tools grouped by domain: reports (summary, orders, pending checkouts, leads, customers, traffic, messages, projects), coupons, popups, automations and their SMS steps, loyalty points and referrals. A constrained escape-hatch tool can reach any endpoint under the admin path prefix for the rare case a dedicated tool does not exist. The MCP tools specification defines a tool as a name, a description and a JSON Schema inputSchema, discovered through tools/list and invoked through tools/call; see the MCP server tools specification. Following that shape kept the server small. A representative definition looks like this:

// Generic shape of one write tool on the server (names simplified).
const couponCreate = {
  name: "coupon_create",
  description:
    "Create a discount coupon. Side effect: visible at checkout immediately. " +
    "Warns when scoped to a product that is not discountable.",
  inputSchema: {
    type: "object",
    properties: {
      code: { type: "string", description: "Coupon code customers type" },
      kind: { type: "string", enum: ["percent", "fixed"] },
      value: { type: "number" },
      product: { type: "string", description: "Product scope, or omit for all" },
      expires_at: { type: "string", description: "Local time, YYYY-MM-DDTHH:MM" },
    },
    required: ["code", "kind", "value"],
  },
};

async function handle(name: string, args: Record<string, unknown>) {
  const res = await adminApi.post("/coupons", args); // same endpoint the panel uses
  return { content: [{ type: "text", text: JSON.stringify(res) }], isError: !res.ok };
}

Each tool description states its side effect in plain words, because that description is the only thing the model reads before deciding. Reads default to a compact payload and accept a verbosity flag, so a daily summary does not flood the context window with every order row.

A database-driven automation builder with SMS steps

Follow-up sequences are rows, not code. An automation has a trigger (a lead source, a purchase, a segment), a set of ordered steps with delays, and a channel per step. The engine runs on a schedule, selects due steps, sends, and records the result. Because the builder is data, the MCP operator can add a step or pause a rule with the same tool calls I would make in the panel.

Popup funnels that feed leads

Popups are also rows: copy, offer, targeting by page and traffic source, and the automation that should pick up the lead. A visitor who leaves a phone number on an article page enters a different sequence from one who asks for a consultation. Tagging the source at capture time is what makes the later reports meaningful.

First-party analytics

Traffic, leads and messages are reported from the platform's own data rather than a third-party script, which is both a reachability decision for this audience and a correctness one: the conversion figures on this page are computed from records the backend wrote, not from a sampled tag.

SEO and GEO infrastructure

Organic search is the acquisition channel, so sitemap, robots, structured data and an llms.txt file are generated as part of the site rather than added by hand. The same discipline applies to the English pages you are reading.

What alternatives did I reject?

Dashboards only. The panel already existed and is still there as a fallback. The argument for stopping at a dashboard is that a human stays in control. The argument against is that the human is the bottleneck, and a dashboard cannot compose actions: read the leads report, notice a source with poor follow-up, edit the automation, create a coupon for that segment. An MCP-operated admin can chain those in one conversation, and the human still reviews the plan before the writes.

Third-party marketing SaaS. Most marketing automation products assume email, card payments and unrestricted network access. None of those hold for this audience. Self-hosting kept customer data local, kept the messaging channels that actually reach people, and removed a per-seat subscription for a one-person team. The cost is that I own the automation engine and its bugs.

A separate agent framework with its own database. I run LangGraph agents in other systems, including OmidGPT, where a multi-provider runtime earns its complexity. Here the operator is a general-purpose model in an MCP client, and the work is in the tool boundary, not the orchestration. A thin server over an existing API was the right size.

What failure modes did I design against?

An AI operator taking irreversible actions

The specification is explicit that there should be a human in the loop with the ability to deny tool invocations, and that clients should prompt for confirmation on sensitive operations. I leaned on that client-side gate and designed the server for reversibility. Toggle tools exist for coupons, popups, automations and referrals so the normal way to stop something is to switch it off, not delete it. Tool descriptions flag the two tools that send real messages to real customers. A read-only mode hides every write tool for reporting sessions. The escape hatch only accepts paths under the admin prefix. Domain invariants are surfaced as warnings: some products are sold without discounts, so a coupon scoped to them would be a silent no-op, and the tool says so rather than letting the operator believe a promotion is live.

Automation double-sends

A scheduled engine that crashes mid-run, or two ticks that overlap, will resend unless sending is idempotent. Each delivery is keyed by recipient, automation and step, and the engine checks that record before sending. Content refreshes that rewrite rules in place are guarded by a marker so a redeploy does not duplicate steps.

Messaging rate limits and provider constraints

SMS providers rate-limit and restrict which origins may send. Broadcasts are batched, failures are logged per recipient, and a segment broadcast is a separate, flagged tool rather than a side effect of editing a rule.

How do I evaluate and keep it reliable?

There is no model-quality benchmark here, because the operator's output is a sequence of tool calls against a business, and the right test is whether the business state after the calls is the one I intended. The reliability work is therefore conventional. A smoke test boots the backend against a throwaway database with the payment SDK stubbed, then exercises reads, one write, the discountability warning, the escape-hatch allowlist and the read-only filter. The server performs a one-shot authentication check at startup and refuses to advertise tools it cannot call. Every automation has a detail report showing what was sent, to whom, and when, so a misconfigured step is visible at the next tick rather than at the end of the month. The daily summary report is the feedback loop: I read it, and if a number moved in a way I did not expect, I ask the operator to explain it from the data before changing anything.

What does it cost to operate?

The operator layer has no fixed cost beyond the machine that already runs the backend. The marginal cost of an action is model tokens, and compact report payloads keep that small. The cost that actually matters is SMS, which is billed per message, so the tools that send messages are the ones with the most explicit descriptions and the most reporting around them. Self-hosting removed a per-seat SaaS subscription; the trade is my time on the automation engine, which for a system this size has been modest.

What were the results?

Three figures I am willing to stand behind. The site draws about 4,000 monthly organic unique visitors. Roughly 90% of that organic traffic comes from Google. Popup funnels convert about 10% of visitors to leads. Qualitatively, the workflows that previously required opening the panel now happen in a conversation, and a change to a coupon, popup or automation is live at once because the front end reads configuration at runtime. The result I value most is that I still write the course.

What did I learn?

  1. Wrap the API you already trust. A thin MCP server over an existing, validated admin API inherits its authorization and its invariants. A second code path would have doubled the bugs.
  2. Reversible beats confirmed. A confirmation prompt is one moment of attention; a toggle is a standing ability to undo. Design for the second, and let the client supply the first.
  3. Tool descriptions are the interface. The model reads them, not the code. Put the side effect, the unit and the domain warning in the description.
  4. Reports are part of the safety system. Every write tool should have a read tool that shows its consequence within one scheduling tick.
  5. Data-driven configuration pays twice. It let a static site change without deploys, and it gave the operator something to edit through the same tools a human uses.

If you are considering the same approach, the MCP development and AI agent development services are where this work sits, and the MCP OAuth architecture guide covers the authentication questions that arise the moment the server is exposed beyond a local client.

Technologies

LayerTechnologyRole
Front endNext.js, static HTMLPersian RTL site, self-hosted fonts and assets, popup surfaces
APIFastAPI (Python)Public and admin endpoints, payment, membership, lead capture
DataPostgreSQLOrders, leads, customers, popups, automation rules and steps
OperatorNode.js MCP serverAdmin API exposed as MCP tools; read-only mode; allowlisted escape hatch
AutomationScheduled engineRuns DB-driven sequences with SMS steps and per-delivery records
ChannelsSMS, Telegram, BaleOutbound customer messaging
SearchSitemap, robots, JSON-LD, llms.txtSEO and GEO infrastructure generated with the site
RuntimeLinux, DockerSingle self-hosted deployment, no CDN dependency

Limitations

The server does not implement its own confirmation step; it relies on the MCP client to prompt before sensitive calls and on reversibility to recover from the rest. That is an acceptable trade for a single trusted operator and would not be for a team. The MCP server is coupled to the admin API's contract, so an endpoint change requires a tool change. The traffic and conversion figures are computed from first-party records and rounded; they are not audited by a third party. The automation engine is a scheduled process, not a queue, which is fine at this volume and would need rethinking at a much larger one. And the system is single-tenant and Persian-language, so the front-end lessons transfer only partly to a different market, while the operator pattern, a thin MCP server over a validated API, transfers directly.