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.
Install the crew next to the connectors it reads through. The package is guidance and wiring only, no HTTP client and no token minting ship in it, so it peers on core and agent-kit and materializes its data tools from whatever backend you already run.
pnpm add @nightowlsdev/agent-seo @nightowlsdev/connectors @nightowlsdev/core @nightowlsdev/agent-kit
# the crew is guidance + wiring only; it reads THROUGH the connectors and peers on core + agent-kitReach for the crew when
- You have a property in Google Search Console + GA4 and want to read, diagnose and propose over your own traffic and index data.
- You want an enforced safety claim, a read-only analyst that provably cannot mutate, with off-property egress and paid spend isolated behind a separate agent.
- You want the measurement caveats encoded, not left to a prompt: the anonymised-query lower bound, the URL-Inspection quota stop, free-first.
- It runs in a trusted / back-office context, both agents are audiences: [] (reachable by an unrestricted run, closed to a restricted caller class).
Look elsewhere when
- You need write actions, publishing content, editing titles or meta, submitting URLs. No write tools ship here (§08); wire your own writer as a separate agent.
- You need Bing / IndexNow / CrUX data, a hosted SEO studio UI, or provider purchasing, none ship; only the Search Console + GA4 connectors are covered.
- You want to expose the analyst to a public / restricted caller class, it is trusted-only, not an anonymous intake agent.
- You expect built-in scheduled monitoring, the package ships no scheduler and no store; you compose that yourself (§07).
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:
| Agent | Holds | Egress |
|---|---|---|
| seo-analyst | Search Console + GA4 reads, the four diagnostic skills | Only its granted data-source tools, no write tools |
| seo-researcher | The web-search arm + optional metered paid tools | Web 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.
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.
import { defineSeoPaidTool, createSeoResearcher } from "@nightowlsdev/agent-seo";
declare const webSearch: SwarmTool;
declare const inputSchema: unknown;
declare function callDataForSeo(input: unknown): Promise<unknown>;
// Metered BY CONSTRUCTION: the wrapper reports each call's spend into the run's cost governor.
const serpLookup = defineSeoPaidTool(
{ unit: "dataforseo.serp", usdPerUnit: 0.0006 }, // or { usdTotal }, or neither (use the governor's price table)
{ name: "serp_lookup", inputSchema, execute: async (input, ctx) => callDataForSeo(input) },
);
// Grant it like any other tool — a hot loop of paid calls now trips cost.maxCostUsd instead of spending invisibly.
const researcher = createSeoResearcher({ tools: { webSearch, paid: [serpLookup] } });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.
Alternatives, limits, and what's out of scope
A single agent gives up the claim. You can hand one custom agent both the GSC reads and the web-search arm, but the honest safety story collapses: grants gate agents, not tools within one, so a single agent holding a read tool and an egress tool cannot promise “read-only, no off-property egress.” The two-agent split is that boundary (§01). Reach for one hand-rolled agent only when you do not need the claim.
Any async tool source, not just the Google connectors. wireSeoTools has a transport-agnostic form: pass materializeTools (an MCP server's listTools, or your own resolver) plus a nameMap to absorb a server's prefix. Remember the MCP-into-analyst rule from §03, an MCP handle's mutating stays unknown, so you must declare mutating: false yourself for the read-only analyst.
// Not a Google connector? Bind the same four capabilities off any async source.
const tools = await wireSeoTools({
materializeTools: (ctx) => mcp.listTools(ctx), // McpConnector.listTools, or your own resolver
nameMap: { search_analytics: "gsc__search_analytics" }, // absorb the server's tool-name prefix
ctx,
});
// The capability NAMES stay the contract; only the transport changed. wireSeoTools re-wraps each handle
// through defineTool so an unregistered MCP copy actually reaches the model (never a silent undefined).Declared limitations (honest, not rounded up)
- Per-agent credentials, but not everywhere. Credential resolution is per executing agent on the execute-time paths (staticBackend/Google-SA, the native vault, the Composio entity, the Nango connection, the MCP client pool), threaded by host-trusted callerSlug. What still resolves per-tenant / first-caller (Option A, deferred): which connection ref a provider materializes, and the out-of-band delivery paths (they run under the run-owner identity).
- Offset-only pagination. The connectors' paginate primitive is kind: "offset" in v1 (both Google actions are POST-body, offset-paginated APIs). A token/cursor arm is a future additive union member.
Out of scope, by design. Charts and UI, site-specific write tools, provider purchasing, Bing / IndexNow / CrUX connectors, and the FR-052 SEO studio section, none ship in this package. It is the diagnostic and research half; the write half, the storefront and the dashboard are yours to wire.
Where to go next
@nightowlsdev/connectors
The Google Search Console + GA4 family, plus the pagination / preflight / async-auth primitives they ride.
@nightowlsdev/metrics
The metric-series store: record the numbers, compare the periods.
Adopt a pre-built agent
Curated skill import, runtime injection, tier config, and approvals, the full journey once.
Building on Night Owls? See the source on GitHub.