HTTP API

The hosted API at umbodi.ai/v1, and how to run the same service yourself.

@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 → Response

The 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"}}'
ActionBodyAnswersCost
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 sitecredits
rankings{ site, project?, keywords?, lang? }{ results: [{ keyword, position, top }] }credits
competitors{ site, project?, keywords?, lang?, deep?, competitor? }discovered competitors; with deep, one competitor's analysiscredits
content{ site, project?, topic?, keyword?, lang? }a drafted page, held to the project's own profilecredits
apply{ project?, only?, preview? }a pull request with your approved improvementsfree

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)

deppurpose
handlersthe route table — pass defaultHandlers(ctx) (below) or your own {action: fn} map
corsOriginsOrigin allowlist for OPTIONS preflight; [] allows nothing, ['*'] allows any
publicJwklicense-verification public key (SP0's verifyLicense)
internalSecret / cronSecretshared 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.
nowclock 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 call
  • ctx.serp — { search(query) -> {results} }, rank-check SERP provider
  • ctx.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 for generate
  • ctx.fetchImpl — fetch impl used by the default sitemap content adapter (analyze)
  • ctx.budget — { canSpend(kind), record(kind) }, spend-cap governor
  • ctx.auth(req) -> {ok} — optional shell-level auth for generate; 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 /run
  • ctx.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:

RouteShellRequiresAuth / gate
analyzeauditEntities (pure core)listIndexableEntities (defaults to a sitemap adapter over {site} + ctx.fetchImpl) — always runslicense, no capability
generatehandleSeoGeneratecallClaude + db: getExistingKeywords, saveProfile, insertProposals, saveHealthlicense + gates.generate
rankingsrunRankMonitorserp + budget + db: saveRank, insertProposals, recordSpendlicense + gates.rankings
competitors (body.deep false)runCompetitorDiscoveryserp + db: upsertCompetitorlicense + gates.competitors
competitors (body.deep true)runCompetitorResearchbody.competitor (client input, e.g. {deep:true, competitor:{domain}}; missing it → 400) + callClaude + webSearch + fetchPage + budget + db: saveAnalysis, insertProposals, recordSpendlicense + gates.competitors
contentrunContentResearchcallClaude + db: saveKnowledge, insertProposalslicense + gates.content
rundrainJobsdb: claimJobs, markDone, markFailed (markCapped optional); dispatches to ctx.handlers by job typex-internal-secret or x-cron-secret — no license
status—noneopen, 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).

  • status is the only route with no auth check.
  • run is authenticated by x-internal-secret (matched against internalSecret) 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 domain claim, 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).

  • OPTIONS on a disallowed Origin → 403; on an allowed one → 204 with 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) => void

Either 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 budget seam's spend cap.
  • An async job-status endpoint (GET /run/:id) to poll a queued job — /run today 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.