Skip to content
Night Owls.dev
Jump to a page
Pre-built agents

The SEO crew. A read-only analyst, a metered researcher.

@nightowlsdev/agent-seo ships two agents over the Google Search Console and GA4 connectors: a read-only, fail-closed seo-analyst that reads, diagnoses and proposes, and a seo-researcher that holds the web-search arm and any optional paid rank/keyword APIs. No connector, HTTP client or token minting lives in the package, you materialize the data tools from the connectors (or a vendor's MCP server) and wire them in.

Two agents, because the boundary IS the split

Audiences and delegate grants gate agents, never tools within one. So the agent boundary is the only mechanism that keeps an egress capability away from a caller's LLM (FR-049's precedent). Splitting read from research is not organizational tidiness, it is the enforcement point:

AgentHoldsEgress
seo-analystSearch Console + GA4 reads, the four diagnostic skillsOnly its granted data-source tools, no write tools
seo-researcherThe web-search arm + optional metered paid toolsWeb search and whatever paid providers you wire

The literal invariant is stated precisely, not rounded up: no write tools and no undeclared built-ins; egress is limited to the granted data-source tools. A zero-egress analyst is unsatisfiable, every connector op is egress: true by construction, so a read still leaves the deployment. The point is that the analyst cannot mutate and cannot reach a source you did not grant it.

Wiring: materialize → await wireSeoTools → sync factories

Connector and MCP tools materialize asynchronously (they proxy a third-party API), so a sync factory cannot discover them. The order is fixed: build the connector defs, then await wireSeoTools, then call the sync factories with the resolved handles.

seo-crew.ts
import { defineSwarm } from "@nightowlsdev/core";
import type { ModelFactory, SecretResolver, StorageAdapter, SwarmContext, SwarmTool } from "@nightowlsdev/core";
import { googleSearchConsoleConnector, ga4Connector, staticBackend } from "@nightowlsdev/connectors";
import { wireSeoTools, createSeoCrew, seoSwarmOptions } from "@nightowlsdev/agent-seo";

// Host-owned handles you already have.
declare const secrets: SecretResolver;
declare const storage: StorageAdapter;
declare const modelFactory: ModelFactory;
declare const webSearch: SwarmTool; // FR-037 webSearch(), an MCP tool, or a custom defineTool
declare const ctx: SwarmContext;

export async function buildSeoSwarm() {
  // 1. The data sources — one ConnectorDef per Google API, over any backend (static env creds shown;
  //    Nango or an MCP server work the same way). url_inspection materializes ONLY when you pass a quota.
  const gsc = googleSearchConsoleConnector({ siteUrl: "https://example.com/", credentialRef: "gsc-sa" });
  const ga4 = ga4Connector({ propertyId: "properties/123", credentialRef: "ga4-sa" });
  const backend = staticBackend({
    connections: {
      "google-search-console": gsc.connection,
      "google-analytics": ga4.connection,
    },
    secrets,
  });

  // 2. AWAIT wireSeoTools FIRST — connector/MCP tools materialize asynchronously (they proxy a third-party
  //    API), so a sync factory cannot discover them. This is the async bridge to the sync factories below.
  const tools = await wireSeoTools({ backend, gsc: gsc.connector, ga4: ga4.connector, ctx });

  // 3. THEN the sync factories. The read-only analyst delegates competitor/off-property questions to the
  //    researcher; both are bundle members, so the delegation closes inside the bundle.
  const crew = createSeoCrew({
    analyst: { tools, modelId: "tier:" },
    researcher: { tools: { webSearch }, modelId: "tier:" },
  });

  // 4. Spread seoSwarmOptions() so the analyst's read-only boundary survives a STORE-REHYDRATED deployment
  //    (an agent loaded from the store has no in-memory AgentDef for defineSwarm to harvest the scope off).
  return defineSwarm({
    ...seoSwarmOptions(),
    storage,
    agents: crew.agents,
    models: { allow: ["openai/gpt-5.5-mini"] },
    modelFactory,
    cost: { maxSteps: 30, maxCostUsd: 0.5 },
  });
}

The capability, not the transport, is the contract. The four canonical capabilities (search_analytics, url_inspection, list_sitemaps, run_report) ARE the connector action names, so the connector case just works with no name map. An MCP server that prefixes its tools passes a nameMap to absorb the prefix. wireSeoTools fails loud at wiring time, never a silent undefined: a mapped target that is missing or ambiguous throws, and a missing required search_analytics throws. It also normalizes every bound handle, an MCP handle is a spread copy that core's tool map would silently omit, so it is re-wrapped through defineTool (preserving name, origin, risk vector and executor) which is what makes “any transport” true rather than asserted.

The read-only boundary is enforced, not stated

Built-ins are assembled once per swarm and spread into every agent, so a co-resident writer's tool would otherwise reach the analyst. Each factory declares a builtinTools allowlist that grants no scopable built-in (only: []); ask, the HITL channel, always survives. defineSwarm harvests that declaration from agents passed as definitions.

A store-rehydrated deployment must ALSO spread seoSwarmOptions(). An agent loaded from the store has no in-memory definition for the harvest to read, so the scope is expressed as deployment config keyed by slug. The two fold most-restrictive-wins, so having both can only narrow. If you renamed an agent and rehydrate, pass the new slug(s): seoSwarmOptions({ analystSlugs: ["my-analyst"] }).

A data tool is admitted fail-closed. createSeoAnalyst accepts a data tool only when it literally declares risk.mutating === false, an absent or unknown flag is refused at construction. A connector op declares mutating: false explicitly; an MCP tool does not, a server's readOnlyHint is escalate-only (it can raise danger, never lower it), so a real MCP tool's mutating stays unknown. To wire an MCP-backed data source into the analyst, declare mutating: false yourself at wiring time, a trusted, first-party assertion. The untrusted hint is not enough.

The five curated skills

Guidance ships as versioned skills, not persona prose (FR-049's route): five module-level defineSkill singletons with zero tools, so core renders them as guidance, never as callable tools.

  • seo-query-diagnosis — striking-distance first, CTR-vs-position, query-to-page mismatch, cannibalisation.
  • seo-decay-detection — decline vs seasonality, with comparison-window discipline.
  • seo-technical-audit — index/coverage states, crawl signals, Core Web Vitals (and when they matter), structured data, quota-aware inspection.
  • seo-competitive-analysis — SERP-overlap competitor selection, keyword/content gap, SERP-feature ownership, the vendor-numbers-not-comparable warning.
  • seo-measurement-honesty — the honesty discipline, granted to both agents.

Replace or drop any of them by name through the override map (not extraSkills, which only appends): createSeoAnalyst({ tools, skills: { "seo-technical-audit": myAudit, "seo-decay-detection": null } }).

Metering paid providers: free first, paid deliberately

The free tier (Search Console + GA4) answers most questions. Paid rank/keyword/backlink APIs (DataForSEO, Ahrefs, …) are optional. Wrap a paid tool with defineSeoPaidTool so it is metered by construction, each call reports its external spend into the run's cost governor via ctx.meterUsage, so a hot loop trips cost.maxCostUsd instead of spending invisibly.

The spend surfaces as swarm.turn_usage.data.externalUsd, a sibling of cost, deliberately not folded into cost.usd. A host that debits only cost.usd under-bills a run whose tools metered external spend, debit cost.usd + (externalUsd ?? 0). A paid tool you inject raw (a plain defineTool) is YOUR metering responsibility, an agent package cannot meter an arbitrary host handle, so it meters only what it owns the construction of.

Three measurement-honesty invariants, encoded

  • Anonymised-query incompleteness. Search Console anonymises roughly half of all queries, so a report grouped by query is a lower bound. Phase B stamps queryDataIncomplete: true on such an envelope; consume it with isQueryDataIncomplete(envelope) / queryDataIncompleteCaveat(envelope) and attach the caveat. Never present clicks-by-query as complete, and never compute a query set's share of site totals.
  • The URL-Inspection quota is a STOP, not a retry. 2,000/day per site. The url_inspection preflight returns (never throws) a typed quota-exhausted refusal, with zero HTTP; recognise it with isQuotaExhausted(result) and stop. Inspect on change or suspicion, never once per URL per scheduled run.
  • Free first. The analyst works with zero paid providers configured, and the persona and honesty skill push the researcher to first-party data before spending a metered unit.

Monitoring composes two other planes

This package ships no scheduler and no store. Periodic monitoring composes the FR-057 recurrence plane (a weekly binding per site that runs the crew on a cadence) with the metric-series store, record the pulled numbers as a series and compare for the trend question.

The storage split is the anti-footgun. Numbers over time go to @nightowlsdev/metrics; competitor page snapshots to @nightowlsdev/knowledge; competitor facts to the graph plane. The graph plane silently keeps the first value forever, so it must never hold a metric, the crew's resolved tool inventories carry no graph-write tool aimed at metrics-shaped data.