API keys#
Keys are bound to one project. The plaintext key is returned once, at creation or rotation; only a hash is stored.
/v1/project/keys
any keyList API keys — All keys of the project, newest first, including revoked ones. #
200 { keys: [{ id, name, scope, created_at, last_used_at, revoked_at }] }
/v1/project/keys
ownerCreate an API key #
| Body | Type | Description |
|---|---|---|
name | string | Label for the key (default key). |
scope | string full · read | read keys can only call GET endpoints; anything else creates a full key. |
201 { id, key, scope } — key is nlv_… and is shown only once
/v1/project/keys/{keyId}/rotate
ownerRotate an API key — Issues a new secret for the key; the old one stops working immediately. #
200 { id, key }
errors 404 key not found
/v1/project/keys/{keyId}
ownerRevoke an API key #
200 { ok: true }
Project and members#
A project is the tenant: it owns keys, members and any number of domains.
/v1/project
any keyGet the current project #
200 { id, name, role, scope }
/v1/project
ownerRename the project #
| Body | Type | Description |
|---|---|---|
name required | string | New project name. |
200 { ok: true }
errors 400 name required
/v1/project
ownerDelete the project — Permanently deletes every domain, graph, key, invite and membership of the project. #
| Query | Type | Description |
|---|---|---|
confirm required | string | Must equal the project name exactly. |
200 { deleted, domains, users }
errors 400 pass ?confirm=<project name> to delete
/v1/project/members
any keyList members #
200 { members: [{ id, email, name, role, created_at }] }
/v1/project/members/{userId}
ownerChange a member's role #
| Body | Type | Description |
|---|---|---|
role required | string owner · editor · viewer | New role. |
200 { ok: true }
/v1/project/members/{userId}
owner/v1/project/invites
ownerCreate an invite #
| Body | Type | Description |
|---|---|---|
role | string editor · viewer · owner | Role for the invitee (default editor). |
201 { token, role, accept_path }
/v1/project/invites
any keyList invites #
200 { invites: [{ id, role, created_at, accepted_by, accepted_at }] }
Domains#
A domain is one knowledge graph with its own ontology, sources, gate, budget and entities. Nothing crosses domain boundaries.
/v1/domains
full keyCreate a domain — Creates an empty domain. To build one from a sentence instead, use autopilot. #
| Body | Type | Description |
|---|---|---|
name required | string | Display name. |
description | string | What the graph covers. The extraction agent reads it. |
ontology | string[] | Entity kinds (default ["person","object","location","event"]). |
max_depth | integer | Link-follow depth from sources (default 2). |
max_pages | integer | Page budget (default 100). |
max_spend_usd | number | Monthly model spend cap in USD (default none). |
201 { id, name, ontology }
errors 400 name is required
/v1/domains
any keyList domains #
| Query | Type | Description |
|---|---|---|
all | string | 1 includes archived domains. |
200 { domains: [{ id, name, description, ontology, max_depth, max_pages, max_spend_usd, archived, crawl_state, created_at, entity_count, page_count }] }
/v1/domains/{domainId}
any keyGet a domain #
200 { id, name, description, ontology, attributes, max_depth, max_pages, max_spend_usd, archived, crawl_state, active_run, published_spec_version, created_at }
errors 404 domain not found
/v1/domains/{domainId}
full keyUpdate a domain — Adding a custom attribute with a new key sends the domain's extracted pages back for a full read on their next re-scrape. #
| Body | Type | Description |
|---|---|---|
name | string | Display name. |
description | string | Scope description. |
ontology | string[] | Entity kinds. Kinds that still have entities cannot be removed. |
attributes | object[] | Custom attributes [{ key, label, kinds, description }], at most 6; [] clears them. |
max_depth | integer | Link-follow depth. |
max_pages | integer | Page budget. |
max_spend_usd | number|null | Monthly spend cap; null removes it. |
200 { ok: true }
errors 400 cannot remove types with existing entities: … · 400 invalid attributes: … · 400 nothing to update
/v1/domains/{domainId}/archive
full keyArchive a domain — Stops crawling, re-scrapes and spend. Data is kept. #
200 { ok: true }
/v1/domains/{domainId}/unarchive
full keyUnarchive a domain #
200 { ok: true }
/v1/domains/{domainId}
ownerDelete a domain — Permanently deletes the domain and its whole graph. #
| Query | Type | Description |
|---|---|---|
confirm required | string | Must equal the domain name exactly. |
200 { deleted }
errors 400 pass ?confirm=<domain name> to delete
/v1/domains/{domainId}/scorecard
any keyGraph scorecard — Deterministic 0–100 quality score with components (coverage, precision, connectivity, corroboration, facets, diversity) and the underlying metrics. #
200 { scorecard: { score, components, metrics } }
Autopilot#
One sentence in; a brief, discovered sources, a crawled and assessed graph out. Stages: brief → discover → crawling → assess → grow → steady (or failed).
/v1/domains/autopilot
full keyStart an autopilot domain #
| Body | Type | Description |
|---|---|---|
prompt required | string | The domain in one sentence, 8–500 characters. |
max_pages | integer | Page budget (default 150). |
max_spend_usd | number | Monthly spend cap (default 25). |
max_cycles | integer | Crawl → assess cycles before steady state (default 3). |
202 { id, stage: "brief" }
errors 400 prompt: describe the domain in one sentence · 400 prompt too long (max 500 chars)
/v1/domains/{domainId}/autopilot
any keyGet autopilot state #
200 { autopilot: { domain_id, prompt, stage, cycle, max_cycles, brief, last_report, history, error, assessed_at, created_at, updated_at } | null }
/v1/domains/{domainId}/autopilot/retry
full keySources and crawling#
Sources are entry points. Crawls stay on source hosts (plus the gate's allowed hosts) within the domain's depth and page budget.
/v1/domains/{domainId}/sources
full keyAdd a source — Registers the URL and queues it immediately. #
| Body | Type | Description |
|---|---|---|
url required | string | An http(s) URL. |
201 { url, queued }
errors 400 valid http(s) url required
/v1/domains/{domainId}/sources/bulk
full keyAdd sources in bulk #
| Body | Type | Description |
|---|---|---|
urls required | string[] | Up to 100 URLs; invalid ones are skipped. |
201 { queued }
/v1/domains/{domainId}/sources/sitemap
full keyAdd sources from a sitemap #
| Body | Type | Description |
|---|---|---|
url required | string | Sitemap URL. The first 50 <loc> entries are queued. |
201 { sitemap, discovered, queued }
/v1/domains/{domainId}/sources
any keyList sources #
200 { sources: [{ id, url, created_at, host, pages_seen, pages_extracted }] }
/v1/domains/{domainId}/sources/{sourceId}
full keyRemove a source — Removes the source only; its pages and entities stay. #
200 { ok: true }
errors 404 source not found
/v1/domains/{domainId}/sources/{sourceId}/crawl
full keyCrawl one source #
200 { queued }
/v1/domains/{domainId}/crawl
full keyCrawl all sources — Queues every source not yet crawled. #
200 { queued }
/v1/domains/{domainId}/recrawl
full keyRe-scrape the domain now — Queues every extracted page. Unchanged pages cost nothing; changed pages are read as a diff. #
200 { queued }
/v1/domains/{domainId}/discover-sources
full keyDiscover sources — Runs an agentic web search for candidate sources. Results are suggestions: review them and add the ones you want. #
202 { queued: true }
errors 409 a discovery is already running for this domain
/v1/domains/{domainId}/discoveries
any keyList discovery runs #
200 { discoveries: [{ id, status, started_at, finished_at, result, sources: [{ url, reason, kinds }], summary, queries_tried }] } — last 5
/v1/domains/{domainId}/plan-frontier
full keyPlan the frontier — Ranks unexplored links, queues the best, and runs gap searches. Also runs weekly on its own. #
202 { queued: true }
errors 409 a frontier plan is already running for this domain
/v1/domains/{domainId}/plans
any keyList frontier plans #
200 { plans: [{ id, status, started_at, finished_at, result, selected, searches, notes, enqueued, frontier_added }] } — last 5
/v1/domains/{domainId}/frontier
any keyFrontier counts #
200 { frontier: [{ status, n }] } — status: candidate | planned | crawled | rejected
Pipeline, runs and usage#
Page statuses: queued, extracted, irrelevant, skipped, failed. A page's last mode: agentic (a model read it), deterministic, or unchanged.
/v1/domains/{domainId}/pages
any keyList pages #
| Query | Type | Description |
|---|---|---|
status | string | Filter by page status. |
limit | integer | Page size (default 100, max 500). |
offset | integer | Rows to skip (default 0). Use the previous response's next_offset. |
200 { pages: [{ id, url, depth, status, last_mode, error, source_host, run_id, fetched_at }], next_offset }
/v1/domains/{domainId}/runs
any keyList crawl runs #
200 { runs: [{ id, status, started_at, finished_at, pages_total, pages_done, pages_unchanged, pages_deterministic, pages_agentic, pages_irrelevant, pages_skipped, pages_failed, entities_added, tokens_input, tokens_output, spend_usd }] } — last 20
/v1/domains/{domainId}/runs/pause
full keyPause crawling #
200 { crawl_state: "paused" }
/v1/domains/{domainId}/runs/resume
full keyResume crawling #
200 { crawl_state: "running", requeued }
/v1/domains/{domainId}/runs/cancel
full key/v1/domains/{domainId}/recipes
any keyList recipes — Legacy deterministic extractors and their health. #
200 { recipes: [{ id, url, version, status, consecutive_failures, last_run_at, last_error, created_at }] }
/v1/domains/{domainId}/recipes/{recipeId}/invalidate
full key/v1/domains/{domainId}/errors
any keyList failed pages #
200 { failed: [{ id, url, depth, error, fetched_at, source_host }] } — last 200
/v1/domains/{domainId}/errors/requeue
full keyRequeue failed pages #
| Body | Type | Description |
|---|---|---|
page_ids | string[] | Up to 100 failed page ids. |
all | boolean | true requeues up to 200 failed pages. |
200 { requeued }
errors 400 pass page_ids or all=true
/v1/domains/{domainId}/dead-letters
any keyList dead letters — Pages that failed extraction three times. #
200 { dead_letters: [{ id, url, depth, error, status, created_at }] }
/v1/domains/{domainId}/dead-letters/{deadLetterId}/requeue
full key/v1/domains/{domainId}/dead-letters/{deadLetterId}/discard
full keyDiscard a dead letter #
200 { ok: true }
/v1/domains/{domainId}/usage
any keyUsage and spend #
| Query | Type | Description |
|---|---|---|
days | integer | Window in days (default 30, max 90). |
200 { days: [{ day, pages_unchanged, pages_deterministic, pages_agentic, pages_failed, tokens_input, tokens_output, spend_usd }], totals }
Gate (spec)#
The gate is the domain's versioned relevance rules. Exactly one version is published; the pipeline enforces it.
/v1/domains/{domainId}/spec
any keyList gate versions #
200 { specs: [{ id, version, status, instructions, include_patterns, exclude_patterns, allowed_hosts, language, created_at, published_at }] }
/v1/domains/{domainId}/spec
full keyCreate a draft gate #
| Body | Type | Description |
|---|---|---|
instructions | string | Plain-language inclusion and exclusion rules the extraction agent applies. |
include_patterns | string[] | URL patterns a page must match (substring, or glob with *). |
exclude_patterns | string[] | URL patterns never to crawl. |
allowed_hosts | string[] | Hosts in scope beyond the source hosts. |
language | string | ISO 639-1 language for descriptions. |
201 { id, version, status: "draft" }
/v1/domains/{domainId}/spec/{specId}/publish
full keyPublish a gate version #
| Query | Type | Description |
|---|---|---|
rejudge | string | 1 gives pages the old gate rejected a second look. |
200 { ok: true, version }
errors 404 spec not found
/v1/domains/{domainId}/dry-run
full keyTest a gate on one URL — Fetch, gate and extract one page without writing to the graph. Costs one extraction. #
| Body | Type | Description |
|---|---|---|
url required | string | Page to test. |
spec_id | string | Draft gate to test (default: the published one). |
200 { url, gate_rejection, relevant, confidence, page_class, entities, relationships, next_urls, model, cost_usd, markdown, tokens }
Graph queries#
Read-only; any project key works. See the graph queries guide for worked examples.
/v1/domains/{domainId}/entities
any keyList entities #
| Query | Type | Description |
|---|---|---|
type | string | person, object, location or event. |
q | string | Substring match on name or description. |
limit | integer | Page size (default 50, max 200). |
offset | integer | Rows to skip (default 0). Use the previous response's next_offset. |
200 { entities: [{ id, type, name, description, aliases, created_at, updated_at }], next_offset }
/v1/domains/{domainId}/entities/{entityId}
any keyGet an entity — The entity with its facet, custom attributes, relationships and up to 50 provenance rows. #
200 { entity, facet, attributes, relationships: [{ relation, direction, entity_id, name, type }], provenance: [{ snippet, url, created_at }] }
errors 404 not found
/v1/domains/{domainId}/entities/{entityId}/neighbors
any keyTraverse the neighborhood #
| Query | Type | Description |
|---|---|---|
depth | integer | Hops, 1–3 (default 1). |
200 { root, nodes: [{ id, name, type }], edges: [{ source, relation, target }] }
/v1/domains/{domainId}/relationships
any keyList relationships #
| Query | Type | Description |
|---|---|---|
relation | string | One canonical predicate, e.g. organizes. |
200 { relationships: [{ id, relation, source_entity_id, target_entity_id, source_name, source_type, target_name, target_type }] } — up to 500
/v1/domains/{domainId}/search
any keySearch entities — Keyword and semantic search merged, best first. #
| Query | Type | Description |
|---|---|---|
q required | string | Search text. |
type | string | Restrict to one kind. |
200 { results: [{ id, type, name, description, match, score }], semantic_enabled }
errors 400 q is required
/v1/domains/{domainId}/timeline
any keyTimeline — Events with their participants, ordered by start. With entity_id, the events connected to any entity. #
| Query | Type | Description |
|---|---|---|
from | string | Earliest starts_at (ISO date). |
to | string | Latest starts_at. |
entity_id | string | Project one entity onto the timeline. |
granularity | string year · month · day | Return counts per bucket instead of events. |
200 { timeline: [{ id, name, description, starts_at, ends_at, participants }] } or { granularity, buckets: [{ bucket, n }] }
/v1/domains/{domainId}/geo.geojson
any keyMap (GeoJSON) — Located entities as a GeoJSON FeatureCollection, each with its connected entities. With entity_id, one entity's footprint. #
| Query | Type | Description |
|---|---|---|
entity_id | string | Project one entity onto the map. |
bbox | string | minLng,minLat,maxLng,maxLat. |
cluster | string | 1 returns grid clusters with counts. |
cell | number | Cluster cell size in degrees (default 2, min 0.25). |
200 GeoJSON FeatureCollection — properties: { entity_id, name, description, address, connected }
/v1/domains/{domainId}/stats
any keyGraph statistics #
200 { totals: { entities, relationships, facts, pages }, entities_by_type, top_relations, pages_by_status, pages_by_mode, recipes_by_status }
/v1/domains/{domainId}/export
any keyExport the graph #
| Query | Type | Description |
|---|---|---|
format | string json · graphml | Default json. |
200 { domain, entities, relationships, facets: { locations, events, persons, objects, attributes } } or GraphML
Curation#
Human corrections. Every change is audited, and deletes leave a tombstone so a re-scrape does not bring the item back. All curation endpoints need write access.
/v1/domains/{domainId}/curation/entities/{entityId}/rename
full keyRename an entity — The old name is kept as an alias. #
| Body | Type | Description |
|---|---|---|
name required | string | New name. |
200 { ok: true, name }
errors 409 an entity of this type with that name exists — merge instead
/v1/domains/{domainId}/curation/entities/{entityId}/merge
full keyMerge into another entity — This entity's edges, provenance and names move to the survivor. #
| Body | Type | Description |
|---|---|---|
into required | string | Survivor entity id. |
200 { ok: true, survivor, aliases }
/v1/domains/{domainId}/curation/entities/{entityId}/facet
full keyReplace an entity's facet — Replaces the whole facet; omitted fields become null. Location: lat, lng, address. Event: starts_at, ends_at. Person: aliases, role. Object: category, identifier. #
| Body | Type | Description |
|---|---|---|
…facet fields | object | Fields for the entity's kind. |
200 { ok: true }
/v1/domains/{domainId}/curation/entities/{entityId}
full keyDelete an entity #
200 { ok: true }
/v1/domains/{domainId}/curation/relationships/{relationshipId}
full keyDelete a relationship #
200 { ok: true }
/v1/domains/{domainId}/curation/audit
full keyCuration audit log #
200 { audit: [{ id, user_id, action, subject_id, details, created_at }] } — last 100
Share links#
Read-only public views of a domain behind an unguessable token — no key needed to read them.
/v1/public/{token}/meta
share token/v1/public/{token}/entities
share tokenShared entities — Needs the graph or search view. #
| Query | Type | Description |
|---|---|---|
limit | integer | Default 100, max 200. |
200 { entities: [{ id, type, name, description }] }
errors 403 view not shared
/v1/public/{token}/timeline
share tokenShared timeline — Needs the timeline view. #
200 { timeline: [{ id, name, description, starts_at, ends_at }] }
errors 403 view not shared
/v1/public/{token}/geo.geojson
share tokenWebhooks#
Signed POSTs when a crawl finishes or a budget is hit. See Webhooks and events for payloads and signature verification.
/v1/domains/{domainId}/webhooks
any keyList webhooks #
200 { webhooks: [{ id, url, events, active, created_at }] }
/v1/domains/{domainId}/webhooks
full keyCreate a webhook #
| Body | Type | Description |
|---|---|---|
url required | string | Receiver URL. |
events | string[] | Any of crawl.finished, budget.hit (default crawl.finished). |
201 { id, secret, events } — secret is whsec_… and is shown only once
/v1/domains/{domainId}/webhooks/{webhookId}/rotate-secret
full key/v1/domains/{domainId}/webhooks/{webhookId}
full keyDelete a webhook #
200 { ok: true }
/v1/domains/{domainId}/webhooks/{webhookId}/test
full keySend a test delivery — Dispatches a test crawl.finished to the domain's subscribed webhooks. #
200 { ok: true, note }
/v1/domains/{domainId}/webhooks/{webhookId}/deliveries
any keyList deliveries #
200 { deliveries: [{ id, event_type, payload, status, attempts, last_error, created_at, delivered_at }] } — last 50
Events and agents#
Live progress: poll with a cursor or stream server-sent events.
/v1/domains/{domainId}/events/poll
any keyPoll domain events #
| Query | Type | Description |
|---|---|---|
since | integer | Cursor from the previous response (default 0). |
200 { events: [{ id, type, payload, created_at }], cursor }
/v1/domains/{domainId}/events
any keyStream domain events (SSE) — Events: run.update, page.update, crawl.finished, budget.hit. Resumes from Last-Event-ID; the server closes the stream after about four minutes, so reconnect. #
| Query | Type | Description |
|---|---|---|
since | integer | Start after this event id. |
200 text/event-stream
/v1/agents
any keyList agent runs #
200 { agents: [{ id, kind, label, status, started_at, finished_at, tokens_input, tokens_output, result, domain_name, domain_id }] }
/v1/agents/{runId}/activity
any key/v1/agents/activity/poll
any keyPoll agent activity #
| Query | Type | Description |
|---|---|---|
since | integer | Cursor (default 0). |
tail | integer | Return the last N rows instead (max 200). |
200 { activity: [{ id, ts, text, agent_run_id, kind, label, status }], cursor }
/v1/agents/events
any keyStream agent activity (SSE) — Event name: agent.activity. #
200 text/event-stream
Feedback#
Report a problem or an idea; it reaches the team building Nolvin.
/v1/feedback
any keySend feedback #
| Body | Type | Description |
|---|---|---|
pathname required | string | Where it happened. |
full_url required | string | Full URL. |
description required | string | What you saw or want (max 20,000 characters). |
category | string idea · bug · critical · other | Default idea. |
selector | string | CSS selector of the element concerned. |
201 { id, status: "new" }
/v1/feedback
any keyList your feedback #
| Query | Type | Description |
|---|---|---|
status | string new · in_progress · shipped · closed · all | Filter (default all). |
limit | integer | Default 50, max 200. |
200 { feedback: [{ id, category, status, description, fix_url, fix_summary, created_at, … }] }
Service#
Unauthenticated.
/api
no authService description #
200 { name, description, docs }
/healthz
no authHealth check — 503 when the database is unreachable or the scheduler is stale. #
200 { ok, db, cron, cron_age_minutes, queued_pages, dead_pages_pending }