Entiscore is an agent that audits a website's digital entity, evaluating structured data, identity consistency, authority signals, and technical accessibility before returning a scored report. The pipeline that powers it was built during the Kiro powered by AWS hackathon organized by Código Facilito, with two real days of development time. This post documents the design decisions that shaped the orchestrator, focusing on how the system stays functional when individual parts of it fail.

Before the URL reaches the analyzers

The first thing the orchestrator does with an incoming URL is not fetch it. It validates it against server-side request forgery. The endpoint resolves the hostname via DNS and rejects the request if that resolution falls within private or reserved IP ranges, or if the hostname maps to localhost. This prevents the system from being used to make the server access internal addresses it shouldn't reach. Only a URL that passes this check proceeds to the analysis flow.

async function validateUrl(url: string): Promise<void> {
  const { hostname } = new URL(url);
  const addresses = await dns.resolve4(hostname);
  
  for (const address of addresses) {
    if (isPrivateIp(address) || isLoopback(address)) {
      throw new Error(`URL resolves to restricted address: ${address}`);
    }
  }
}

The SSRF check happens before any network request is made to the target. It's a deliberate boundary: the orchestrator treats user-supplied URLs as untrusted input and enforces that boundary before doing anything else with them.

Building the analysis context

Once the URL passes validation, the orchestrator fetches the full HTML of the site and its robots.txt using three MCP-compatible tools: fetchPage, fetchRobotsTxt, and checkUrlAccessibility. Each of these follows the input and output contract that MCP defines for its tools, which means the orchestrator calls them as direct TypeScript functions in production while the same interface is available through a real MCP server during development with Kiro.

With those two inputs the orchestrator builds a single analysis context object:

interface AnalysisContext {
  html: string;
  statusCode: number;
  responseTimeMs: number;
  headers: Record<string, string>;
  robotsTxt: string | null;
}

That context is the only thing each of the four analyzers receives. None of them makes network requests of their own. None of them fetch additional data. They receive what the orchestrator already fetched and evaluate it deterministically from there.

Running the four analyzers in parallel

The orchestrator dispatches all four analyzers simultaneously using Promise.allSettled. The choice between Promise.allSettled and Promise.all is worth explaining because it determines how the system behaves when something breaks.

With Promise.all, if a single analyzer throws an exception the entire promise rejects and the full analysis stops. One bad analyzer takes everything down with it. With Promise.allSettled, each analyzer resolves or fails independently, and the orchestrator receives all four results regardless of whether any individual one had a problem. The axis that failed gets marked with a distinct status while the other three continue delivering their results normally.

const [structured, identity, authority, technical] = await Promise.allSettled([
  analyzeStructuredData(context),
  analyzeIdentityConsistency(context),
  analyzeAuthoritySigns(context),
  analyzeTechnicalAccessibility(context),
]);
 
const results = {
  structuredData: structured.status === "fulfilled" 
    ? structured.value 
    : { status: "failed", findings: [], score: 0 },
  identityConsistency: identity.status === "fulfilled"
    ? identity.value
    : { status: "failed", findings: [], score: 0 },
  authoritySigns: authority.status === "fulfilled"
    ? authority.value
    : { status: "failed", findings: [], score: 0 },
  technicalAccessibility: technical.status === "fulfilled"
    ? technical.value
    : { status: "failed", findings: [], score: 0 },
};

Each analyzer evaluates a specific dimension. The structured data analyzer looks for schema markup in JSON-LD, Microdata, and RDFa, identifies which types the site declares, and checks field completeness against what schema.org recommends for each type. The identity consistency analyzer compares the name declared in schema markup against the og:title and the HTML title tag, and verifies that sameAs links and external profile URLs actually respond when accessed. The authority signals analyzer detects outbound links to recognized platforms and looks for authorship metadata and mentions of certifications or open source contributions in the site's text. The technical accessibility analyzer checks HTTP response codes, response time, presence of essential metadata like title and meta description, Open Graph tags, robots.txt blocking behavior, and whether the site depends entirely on client-side JavaScript without server-rendered content.

Scoring with partial results

Once all four analyzers complete, the orchestrator passes their results to the scoring engine. The base weights are fixed: 30% for structured data, 20% for identity consistency, 20% for authority signals, and 30% for technical accessibility. When one or more axes ended with a failed or partial status, those axes are excluded from the calculation and their weights are redistributed proportionally across the axes that evaluated correctly.

function calculateScore(results: AnalyzerResults): ScoringResult {
  const weights = {
    structuredData: 0.30,
    identityConsistency: 0.20,
    authoritySigns: 0.20,
    technicalAccessibility: 0.30,
  };
 
  const active = Object.entries(results).filter(
    ([, result]) => result.status !== "failed"
  );
 
  const totalWeight = active.reduce(
    (sum, [key]) => sum + weights[key as keyof typeof weights], 
    0
  );
 
  const normalizedScore = active.reduce((sum, [key, result]) => {
    const weight = weights[key as keyof typeof weights] / totalWeight;
    return sum + result.score * weight;
  }, 0);
 
  return {
    score: Math.round(normalizedScore),
    maturityLevel: getMaturityLevel(normalizedScore),
    activeAxes: active.length,
  };
}

This means the final score always reflects only what was actually measured. A report where one analyzer failed doesn't produce a zero for that axis or artificially deflate the overall score. It recalculates based on what succeeded, and labels the missing axis clearly in the output.

Where Claude enters the pipeline

Claude API enters at three specific points, all of them after the deterministic evaluation is complete.

The first is the executive summary, a paragraph in plain language that describes the overall state of the site based on the four calculated results, written for someone without technical SEO knowledge.

The second is the action plan. Claude takes the warning and critical findings from all four axes and generates prioritized recommendations, each with an estimated effort level and in several cases a ready-to-use code snippet, like the exact JSON-LD that would fix a missing field.

The third is the conversational assistant, which receives the complete generated report as context and answers questions scoped specifically to that analysis.

What matters architecturally is that the scoring, the maturity level, and the concrete findings always come from the deterministic evaluation. Claude only touches the presentation layer.

The action plan generation has an explicit fallback. If the Claude API call fails for any reason, the orchestrator switches automatically to a rule-based system that generates the same type of prioritized recommendations from the findings without the more polished AI-generated language. The user gets a complete, actionable report either way.

async function generateActionPlan(
  findings: Finding[]
): Promise<ActionPlan> {
  try {
    return await generateWithClaude(findings);
  } catch {
    return generateWithRules(findings);
  }
}

Persisting and sharing results

Before returning the complete report to whoever requested it, the orchestrator persists it in Supabase along with a short, unique, human-readable code generated at that moment. That code is what makes it possible to access the same report later through a public route without repeating the analysis, and what enables the result to be shared by direct link.

The URL comparison feature reuses the same orchestrator without changes. For a comparison, the orchestrator runs twice in parallel, once per submitted URL, each run following the full flow from start to finish. When both complete, a comparative summary is generated from the two full results and persisted with its own unique code following the same mechanism as an individual analysis.


Entiscore is available at entiscore.vercel.app. Built with Next.js, TypeScript, Supabase and Claude API for the Kiro powered by AWS hackathon by Código Facilito.