@ascent/seo-agent/service is the trusted HTTP surface: one handler, N routes, wrapping the same backend shells the SDK uses locally. It holds your secrets server-side and answers the SP0 browser proxy's POST {serviceUrl}/{action} contract, plus a plain REST surface for API-only callers.
import { createSeoService } from '@ascent/seo-agent/service'
const svc = createSeoService(deps) // deps = server seams, see below
const response = await svc.handleRequest(request) // Web Fetch Request → ResponseThe hosted service: umbodi.ai/v1
You do not have to host this. Umbodi runs the same contract at https://umbodi.ai/v1: every action is POST https://umbodi.ai/v1/{action} with Authorization: Bearer <license key> and a JSON body, and paid actions are charged in credits to the key's account at the app's prices.
curl -s https://umbodi.ai/v1/status
curl -s -X POST https://umbodi.ai/v1/analyze -H "authorization: Bearer $ASCENT_LICENSE" \
-H 'content-type: application/json' -d '{"url":"https://example.com"}'
curl -s -X POST https://umbodi.ai/v1/rankings -H "authorization: Bearer $ASCENT_LICENSE" \
-H 'content-type: application/json' -d '{"site":{"domain":"example.com"}}'| Action | Body | Answers | Cost |
|---|---|---|---|
status | — (GET or POST, no key) | { hosted, actions, notYet, configured } | free |
analyze | { url } | { issues, url, severity, audit } | free |
generate | { site, project?, lang? } | { profile, facets, … } — built from a crawl of the site | credits |
rankings | { site, project?, keywords?, lang? } | { results: [{ keyword, position, top }] } | credits |
competitors | { site, project?, keywords?, lang?, deep?, competitor? } | discovered competitors; with deep, one competitor's analysis | credits |
content | { site, project?, topic?, keyword?, lang? } | a drafted page, held to the project's own profile | credits |
apply | { project?, only?, preview? } | a pull request with your approved improvements | free |
Your own sites only. site ({domain} or {url}) must match one of the key's projects by host, or project must name one; otherwise the call is refused and nothing is charged. analyze and status need no project. Keywords default to the project's target keywords.
content writes about your business, not a rival's. The draft may state only what the project's own profile says about the business, and a topic that names one of the project's tracked competitors (or is an analysis's "no data" note) is refused before anything is charged.
Refusals are { ok: false, why } with the status that explains them: 401 no_license / license_invalid / license_expired; 400 request_must_name_a_project (with projects), url_required, no_keywords, topic_or_keyword_required; 402 insufficient_credits, project_cap_reached; 403 project_not_on_this_account; 404 unknown_action; 422 topic_refused (with reason: names_competitor or meta_statement; not charged); 502 no_response / parse_failed when the model wrote nothing usable (with refunded: true once the credits are given back). A deployment without a configured provider key answers 200 { configured: false }.
What a key cannot do: merge, or start the scheduled loop. Merging is the project owner's own agent, behind a per-project opt-in a key cannot set.
Wiring createSeoService(deps)
| dep | purpose |
|---|---|
handlers | the route table — pass defaultHandlers(ctx) (below) or your own {action: fn} map |
corsOrigins | Origin allowlist for OPTIONS preflight; [] allows nothing, ['*'] allows any |
publicJwk | license-verification public key (SP0's verifyLicense) |
internalSecret / cronSecret | shared secrets accepted for /run (worker/cron auth) |
gates | {action: capability} — which capability each license-gated action requires, e.g. { generate: 'contentEnabled' }. An action omitted from gates still needs a valid license, just no extra capability check. |
now | clock override for tests; defaults to the real clock |
handleRequest is a plain (Request) => Promise<Response> — no Node globals, runs natively on Deno/Cloudflare/Bun/Vercel Edge/Node 18+.
defaultHandlers(ctx) — the real routes
defaultHandlers(ctx) builds the seven routes below over the engine's existing backend shells. ctx is the seam vocabulary the shells need — same shape whether you build it by hand or reuse resolveRequestDeps/resolveGenerateDeps/etc. from service/requestDeps.mjs:
ctx.callClaude(prompt) -> Promise<string>— LLM callctx.serp—{ search(query) -> {results} }, rank-check SERP providerctx.webSearch—{ search(query) -> {results} }, general web search (deep competitor research)ctx.fetchPage—{ fetch(url) -> {ok,url,text} }, page fetcher (shell SSRF-guards the URL)ctx.fetchDocs(docsSource) -> Promise<string>— docs source forgeneratectx.fetchImpl— fetch impl used by the default sitemap content adapter (analyze)ctx.budget—{ canSpend(kind), record(kind) }, spend-cap governorctx.auth(req) -> {ok}— optional shell-level auth forgenerate; the router already
authenticated the request, so if you don't supply this it defaults to an always-ok stub
ctx.handlers—{ [jobType]: (payload) => Promise<result> }, job-type dispatch for/runctx.db— your storage backend, read by exact method name:getExistingKeywords,
getPageMeta, saveProfile, saveHealth, saveRank, upsertCompetitor, saveAnalysis, saveKnowledge, insertProposals, recordSpend, claimJobs, markDone, markFailed, markCapped. Any of these may also be set flat on ctx (e.g. ctx.claimJobs) to override the ctx.db-sourced value.
Fail-soft, not 500. Each route checks only the seams *it* needs before touching a shell; if one is missing, it returns 200 {configured:false} instead of throwing:
| Route | Shell | Requires | Auth / gate |
|---|---|---|---|
analyze | auditEntities (pure core) | listIndexableEntities (defaults to a sitemap adapter over {site} + ctx.fetchImpl) — always runs | license, no capability |
generate | handleSeoGenerate | callClaude + db: getExistingKeywords, saveProfile, insertProposals, saveHealth | license + gates.generate |
rankings | runRankMonitor | serp + budget + db: saveRank, insertProposals, recordSpend | license + gates.rankings |
competitors (body.deep false) | runCompetitorDiscovery | serp + db: upsertCompetitor | license + gates.competitors |
competitors (body.deep true) | runCompetitorResearch | body.competitor (client input, e.g. {deep:true, competitor:{domain}}; missing it → 400) + callClaude + webSearch + fetchPage + budget + db: saveAnalysis, insertProposals, recordSpend | license + gates.competitors |
content | runContentResearch | callClaude + db: saveKnowledge, insertProposals | license + gates.content |
run | drainJobs | db: claimJobs, markDone, markFailed (markCapped optional); dispatches to ctx.handlers by job type | x-internal-secret or x-cron-secret — no license |
status | — | none | open, no auth |
status always returns 200 { configured: { claude, serp, storage } } — a boolean per top-level seam, so a caller can tell what's wired without a license.
Routing, auth and errors
The path segment after the last / selects the action (POST /:action, matching the SP0 proxy's {serviceUrl}/{action}); the router doesn't otherwise care about HTTP method beyond OPTIONS, which is handled separately as a CORS preflight. Request bodies are read as JSON ({} on a GET or a parse failure).
statusis the only route with no auth check.runis authenticated byx-internal-secret(matched againstinternalSecret) or
x-cron-secret (matched against cronSecret) — no license involved. Missing/wrong secret → 401.
- Every other route requires
Authorization: Bearer <license-token>, verified with SP0's
createLicenseGate({ licenseKey, publicJwk, now }). Missing/invalid/expired → 401 {error}. If the action has an entry in gates, the resulting gate must also .allows(capability) or the route returns 403 {licensed:false, reason}.
- Domain-bound tokens. If the (already-verified) license token carries a
domainclaim, it's
enforced against the request's Origin header: the origin's host must equal payload.domain, or the request is rejected 403 {error:'token domain does not match origin'}. A token with no domain claim is a general API license and is never origin-bound (unchanged behavior). A domain-bound token used with no Origin header (server-to-server calls don't send one) is allowed through — there's no origin to bind against in that case.
- Unknown action →
404 {error}. - An uncaught handler exception →
500 {error}(the fail-soft{configured:false}guards above
are what keep normal "not wired yet" cases out of this path).
OPTIONSon a disallowedOrigin→403; on an allowed one →204with the CORS headers.
Deploying
Web-standard (edge/Deno/Cloudflare/Bun) — handleRequest *is* the handler:
import { createSeoService, defaultHandlers } from '@ascent/seo-agent/service'
const svc = createSeoService({ handlers: defaultHandlers(ctx), corsOrigins, publicJwk, internalSecret, gates })
Deno.serve((request) => svc.handleRequest(request))Node (Vercel Node functions, Express, plain http) — wrap it with toNodeHandler:
import { createSeoService, defaultHandlers, toNodeHandler } from '@ascent/seo-agent/service'
const svc = createSeoService({ handlers: defaultHandlers(ctx), corsOrigins, publicJwk, internalSecret, gates })
export default toNodeHandler(svc) // (req, res) => voidEither way it's one function, N routes — deploying the service adds exactly one serverless function regardless of how many actions it serves (sidesteps Vercel's Hobby 12-function limit).
The init --profile=api scaffold (cli/init.mjs's apiServiceEntry()) ships a default gates map of { generate: 'contentEnabled', content: 'contentEnabled', rankings: 'contentEnabled', competitors: 'contentEnabled' } — rankings/competitors hit paid SERP/LLM providers just like generate/content, so they're gated the same way by default (safe-by-default, not left callable by any valid license including free). This is an overridable default for the generated deployment entry, not a router.mjs behavior change — adjust it to match your real pricing tiers. Per-tier budget metering is deferred (see "Not shipped yet" below).
Usage telemetry (optional — enables per-seat license tracking)
The service can report fire-and-forget usage pings to your licensor so they can track per-license usage and count distinct developers against a seat limit. It is entirely optional, never throws, and never affects a response — a service with no telemetry configured behaves exactly as before.
Wire it once, per deployment, by passing a recordUsage dep built with makeAscentTelemetry:
import { createSeoService } from '@ascent/seo-agent/service'
import { makeAscentTelemetry } from '@ascent/seo-agent/service/telemetry'
const recordUsage = makeAscentTelemetry({
url: 'https://<your-licensor-project>.supabase.co/functions/v1/license-telemetry', // where pings go
token: process.env.ASCENT_LICENSE, // the license this deployment runs under
seat: process.env.ASCENT_SEAT, // OPTIONAL: a stable per-developer/deployment id (see below)
})
const service = createSeoService({ /* …other deps… */, recordUsage })After a request authenticates, the router calls recordUsage({ kid, action }) (no-op if recordUsage is absent). The ping body is { action, seat? } sent with the Authorization: Bearer <license> header; the licensor takes the kid from the verified token, not the body.
The seat id — what to set it to
seat is an opaque, stable identifier for one developer or deployment — no PII, just a tag the licensor groups by. Because each developer sends a *different* seat, the licensor can count distinct seats and detect a 5-seat key being used by 12 developers.
- Set it to a per-developer value: an assigned developer/deployment id, or a hash of a stable machine/
user identifier. Keep it consistent for the same developer across runs.
- Omit it and telemetry still works, but the licensor can only count distinct *accounts* (a coarser
lower bound), not distinct developers — so true per-seat enforcement needs seat set.
The receiving side (edge function + distinct-seat counting + admin over-limit view) lives with the licensor; this package only emits the ping.
Not shipped yet (planned)
- Rate limiting / per-tenant billing metering beyond the
budgetseam's spend cap. - An async job-status endpoint (
GET /run/:id) to poll a queued job —/runtoday is
synchronous enqueue/drain/selfcheck only.
- Streaming responses — every route returns a complete JSON body.
- Persisted API-key management — auth is the license token, there's no separate key store.
See docs/SDK.md for the local (in-process) facade this service wraps, and docs/SECURITY.md (in the licensed distribution) for the human-approval gate and budget cap that apply once you're licensed.