Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 10 min read

Your Software Can't Heal What It Can't See: Support Is the Missing Loop

Picture a customer writing in to your SaaS product: "new leads stopped showing up in our CRM." Your dashboards are green. No exceptions in error tracking, no 5xx spike, p95 latency flat. By every machine-readable measure

Your Software Can't Heal What It Can't See: Support Is the Missing Loop

Picture a customer writing in to your SaaS product: "new leads stopped showing up in our CRM." Your dashboards are green. No exceptions in error tracking, no 5xx spike, p95 latency flat. By every machine-readable measure the app is healthy.

It isn't. It's broken for that one customer, and the only sensor that noticed is a human typing into a chat widget. If you run support for any software product, you've had some version of this ticket.

I build Superrelay, a support workspace for software teams, so I'm biased. But I've come to believe this: most "self-healing software" talk skips the part where most bugs actually show up. The loop is missing its best sensor, and that sensor is your support inbox.

This post covers what self-healing software actually is, where it goes blind, and how to wire support into the loop without letting an AI agent loose on production.

What "self-healing" means today

The idea is old. IBM's autonomic computing vision (Kephart and Chess, 2003) listed self-healing next to self-configuring, self-optimizing and self-protecting, and described a control loop: monitor, analyze, plan, execute. Strip away the vocabulary and every modern version is the same four steps.

The classic self-healing loop: detect, diagnose, remediate, verify

You probably already run parts of it:

  • Restart on failure. A Kubernetes liveness probe that fails past its threshold gets the container killed and restarted. Crude, and very effective against deadlocks.
  • Stop the bleeding. A circuit breaker trips after repeated failures, fails fast, and can reset itself once the dependency recovers.
  • Turn it off. Long-lived ops toggles act as kill switches that let operators degrade a non-critical feature without a deploy. Pete Hodgson notes they "could be seen as a manually-managed Circuit Breaker."
  • Roll it back. Argo Rollouts analysis can query a success-rate metric during a canary and abort the rollout, sending traffic back to the stable version, when the metric fails.
  • Write the fix. The newest layer. Sentry's Seer Autofix does root-cause analysis on an issue, proposes a fix, and can be configured to open a PR. It can also hand off to a coding agent.

Every one of these is a good idea. And every one of them depends on the same thing: the failure has to be visible to a machine. A probe has to fail. An exception has to be thrown. A metric has to move.

The bugs telemetry can't see

Try this: open your last 50 support tickets and count how many have a matching error event. Whatever that number is, it's the real coverage of your self-healing loop. The rest reached you only because a customer took the time to write in.

The invisible-bug iceberg: telemetry sees exceptions and 5xx spikes; support tickets reveal desynced settings, environment conflicts, confusing UX, stale docs and silent integration failures

Here's what's under the waterline:

  • State that's wrong for one account. A setting saved in your database but never applied on the platform side.
  • Environment conflicts. A customer's browser extension, a corporate proxy, another vendor's script fighting yours.
  • "Success" that isn't. Plenty of third-party APIs answer 200 when the operation didn't happen. Under the GraphQL-over-HTTP spec draft, an error raised while executing an operation doesn't fail the request at the HTTP level: the response is still a 2xx, and the failure lives in an errors array in the body. If you only check the status code, your logs say everything is fine.
  • Integrations that quietly stop. An expired token, a webhook subscription that got dropped.
  • Confusing UX. Nothing is broken; the customer just can't find the button. That's still a defect.
  • Wrong docs. The help article describes last month's UI. The code is fine; the product is still broken for anyone who reads it.

None of these throw. All of them produce tickets. Support is the sensor network for the failures your telemetry can't see. Most teams treat that sensor as a cost center instead of a signal source.

Closing the loop: support as a first-class sensor

Here's the loop I think small teams should build. It has two speeds: fix this customer now, and fix the class of bug so the next ticket never arrives.

The closed loop: ticket arrives, structure the signal, support agent fixes the instance with approval, coding agent fixes the class of bug, team reviews and ships, every reporter is notified

1. Turn tickets into structured signals

A raw ticket is like an unparsed log line. Before anything automated can use it, you need:

  • Tags that mean something to engineering (bug:settings-sync, not urgent).
  • Context attached automatically: account ID, plan, app version, the page they were on.
  • Clustering: five customers describing one bug in five different ways should become one issue with five reporters. This is where an LLM is genuinely useful. Grouping fuzzy natural-language reports is exactly what it's good at.

2. Let a support agent fix the instance, safely

If your agent can only talk, it can only deflect. The useful version has read access to logs and account state, plus a short list of allowlisted actions it can propose. You can expose those over MCP or a plain HTTPS endpoint.

The MCP spec itself says there should always be a human in the loop who can deny tool invocations, and that clients should prompt for confirmation on sensitive operations. Take that seriously.

3. Let a coding agent fix the class of bug

Once a cluster is tagged, it becomes a task: a coding agent reads the tickets (via CLI or MCP), reproduces the issue with a failing test, fixes it, and updates the docs in the same PR. A human reviews and merges. Your existing CI, canaries and flags still apply.

4. Tell the customer

The step everyone forgets. When the fix ships, every reporter in the cluster gets a message with a link. That's what turns "this app is buggy" into "these people actually fix things."

Worked example: an integration that silently desyncs for one account

This is an illustrative example, simplified from a common class of SaaS integration bug. The code is generic, not any product's real API.

The setup. A SaaS app lets each customer workspace turn on "Sync new leads to CRM." The app stores crmSyncEnabled in its own database and registers a webhook subscription in the customer's CRM through the CRM's GraphQL API. Admins toggle the setting in the app's settings page.

The bug. The CRM provider recently tightened its rate limits. During a bulk settings save, the subscription call gets rate-limited, and the GraphQL response is a 200 with a RATE_LIMITED error in the body. The code only checks res.ok, so the database says true and the CRM has no subscription. No exception, no alert.

The ticket. "New leads stopped showing up in our CRM."

Before and after timeline of the same bug: without the loop it ends with a manual DB patch and stale docs; with the loop it ends with a merged fix and every reporter notified

Instance fix. The support agent calls a read-only tool that compares app state with the CRM-side state, then proposes a scoped write. Here's a sketch of both tools with the MCP TypeScript SDK:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({ name: "sync-app-support", version: "0.1.0" });

// Read-only: safe to call freely. Returns evidence the agent can cite.
server.registerTool(
  "diff_account_settings",
  {
    title: "Compare app settings with the account's CRM integration",
    description: "Read-only. Shows where our DB and the CRM-side subscription disagree.",
    inputSchema: { accountId: z.string().uuid() },
    annotations: { readOnlyHint: true },
  },
  async ({ accountId }) => {
    const diff = await diffSettings(accountId); // your code
    return { content: [{ type: "text", text: JSON.stringify(diff, null, 2) }] };
  }
);

// Write: one account per call, re-applies existing DB values only, audit-logged.
server.registerTool(
  "resync_account_settings",
  {
    title: "Re-apply saved settings to one account",
    description: "Re-registers the CRM subscription from DB values. Requires human approval.",
    inputSchema: { accountId: z.string().uuid(), ticketId: z.string() },
    annotations: { destructiveHint: false, idempotentHint: true },
  },
  async ({ accountId, ticketId }) => {
    await assertApproved(ticketId, "resync_account_settings"); // human said yes
    await rateLimit(`resync:${accountId}`, { max: 3, perHours: 24 });
    const result = await resyncSettings(accountId); // must check the GraphQL errors array
    await audit.log({ ticketId, accountId, action: "resync", result });
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  }
);

Notice what the write tool can't do: touch more than one account, take arbitrary values, or run without an approval tied to a ticket. One more thing: the spec warns clients to treat annotations as untrusted unless they come from a trusted server. They're hints, not enforcement. The enforcement lives in your handler.

Class fix. Two more customers report the same thing that week, and the cluster gets tagged bug:settings-sync. That tag fires a webhook that turns the cluster into an agent-ready GitHub issue:

// POST /hooks/support-tagged β€” called by your help desk when a ticket is tagged.
// The payload shape here is hypothetical; adapt it to your help desk's webhooks.
import { Octokit } from "@octokit/rest";
const gh = new Octokit({ auth: process.env.GITHUB_TOKEN });

export async function handler(req: Request) {
  if (!verifySignature(req)) return new Response("bad signature", { status: 401 });
  const { tag, cluster } = await req.json();
  if (!tag.startsWith("bug:")) return new Response("ignored");

  const body = [
    `**Cluster:** ${cluster.id} (${cluster.tickets.length} reports)`,
    `**Accounts:** ${cluster.tickets.map((t) => t.accountId).join(", ")}`,
    `**Summary:** ${cluster.summary}`,
    "",
    "### Agent instructions",
    "1. Read the linked tickets via the support CLI/MCP.",
    "2. Write a failing test that reproduces the report.",
    "3. Fix it, then update any help article that describes this behavior.",
    "4. Open a PR. Do not merge.",
    "",
    ...cluster.tickets.map((t) => `- ${t.url}`),
  ].join("\n");

  await gh.issues.create({
    owner: "acme", repo: "sync-app",
    title: `[support] ${cluster.title}`,
    body,
    labels: ["from-support", "agent-task"],
  });
  return new Response("ok");
}

Point whichever coding agent you use at agent-task issues. In this example, the PR it opens adds a test with a mocked RATE_LIMITED response, makes the subscription writer check the errors array and retry with backoff, and edits the help article that claimed changes "apply instantly." A human merges it. The three reporters get a "fixed, here's what happened" message.

The detection didn't come from telemetry. Every step after detection looks like the self-healing loop you already know.

Guardrails (non-negotiable)

Putting an agent between customers and production means designing for the day it's wrong:

  • Read first, write rarely. Most of the value comes from read-only tools that pull account state and logs.
  • Ask before acting. Every write needs an explicit human or customer confirmation tied to a ticket.
  • Cite sources. An answer or diagnosis should link the article, log line or diff it came from. If it can't cite, it should say so.
  • Limit the blast radius. One account per call, fixed parameters, rate limits, no bulk operations.
  • Audit everything. Who approved what, when, with what result.
  • Humans merge. Coding agents open PRs; they don't push to main or deploy.
  • Scoped keys. The agent's credentials should only see the tools you intend it to use.

Where Superrelay fits

Full disclosure, this is the problem I'm building for. We built Superrelay to run support for our own software products, and it's shaped around this loop:

  • One inbox for every product, across chat, email, Slack and in-app, with product-specific knowledge.
  • An AI agent that cites the article it answered from and can start in draft or supervised mode.
  • Product actions through an HTTPS endpoint you register. You choose which actions exist and which need customer confirmation.
  • Docs as code: help-center articles live as Markdown in your GitHub repo. Merging publishes them, and pull requests that change documented behavior get flagged with the affected articles.
  • A CLI and MCP server, so Claude Code, Cursor, Codex and friends can read conversations (relay list --limit 20 --json) and group recurring issues.
  • Priced by products and seats, never per ticket, because charging more when your product breaks more is a strange incentive.

To be clear about the limits: Superrelay doesn't autonomously repair or deploy your code. It gives your agents and your team the customer context and safe actions; you connect the steps and review the work.

A checklist you can apply this week

  1. Audit 50 tickets. How many had a matching error event? That gap is your blind spot.
  2. Create five engineering tags for your most common ticket types and use them consistently.
  3. Attach context automatically: account ID, plan, app version, current page.
  4. Write one read-only diagnostic tool, like diff_account_state, that support (human or AI) can run.
  5. Write one safe, scoped, idempotent write action with approval and an audit log.
  6. Check every "success" path. Grep for places you trust a 200 from a GraphQL API without checking errors.
  7. Pipe tagged clusters into GitHub issues with reproduction instructions for your coding agent.
  8. Put docs in the PR template: "Which help article does this change?"
  9. Close the loop: notify every reporter when the fix ships.

Self-healing software isn't a smarter pod restart. It's a feedback loop, and the most honest signal in it is a customer telling you something's wrong. Wire that in, and your product starts fixing the bugs your dashboards never saw.

If you're building this loop yourself, I'd love to hear how you're doing it in the comments. If you'd rather not build the support half from scratch, Superrelay has a free plan.

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.