Skip to main content
Version: Next

API Reference

Base URL: http://localhost:8080 (direct) or http://localhost:3000/api (via dashboard proxy)

All endpoints return JSON. Error responses use {"error": "message"}.

Health​

GET /health​

Liveness probe. Returns immediately, does not check dependencies.

curl http://localhost:8080/health
{"status": "ok"}

GET /health/ready​

Readiness probe. Checks MongoDB connectivity.

curl http://localhost:8080/health/ready
{"status": "ok", "services": {"mongodb": {"status": "ok"}}}

Returns 503 (with "status": "degraded" and per-service errors under services) if a dependency is unreachable.


System​

GET /api/v1/system​

Component inventory for the running deployment — the services and the in-process workers that ride inside them, each with the version it reports. The list is built from a registry, not hardcoded, so additional components registered by a build appear automatically.

curl http://localhost:8080/api/v1/system
{
"data": {
"components": [
{"name": "API", "kind": "service", "version": "0.10.0", "commit": "a1b2c3d", "build_date": "2026-06-01T12:00:00Z"},
{"name": "Agent", "kind": "service", "version": "0.10.0", "note": "agent binary bundled in the API image, run as a subprocess; shares the API image version"},
{"name": "Schema indexing", "kind": "worker", "version": "0.10.0", "runs_in": "API", "note": "runs in-process inside the API service; shares its image version"},
{"name": "Validation jobs", "kind": "worker", "version": "0.10.0", "runs_in": "API", "note": "runs in-process inside the API service; shares its image version"}
]
}
}

Each component carries:

  • name — human-facing component name.
  • kind — service (a deployable image) or worker (a background loop running inside a parent service).
  • version — the reported version. For a worker this is its parent image's version. For the Agent it depends on RUNNER_MODE: in kubernetes mode it is the tag of the configured agent image (AGENT_IMAGE); in subprocess mode (the default) it is the bundled agent binary's version (the same as the API's), since AGENT_IMAGE is not used.
  • commit, build_date — git commit and build timestamp, when stamped at build time (see Configuration → Build metadata).
  • runs_in — for a worker, the parent service it runs inside.
  • note — clarification, e.g. that a worker shares its parent image's version, or that the Agent runs as a Job rather than a live service.

Only components that are actually running are listed: the Schema indexing worker appears only when vector search (Qdrant) is configured, since it does not start otherwise.

The version values are injected at image build time via -ldflags; an un-stamped local build reports the source-tree default (0.4.0-dev). The Dashboard surfaces this inventory on its System page, adding its own build version (which the API cannot know, as the dashboard is a separate image).


Providers​

GET /api/v1/llm/layers​

List the LLM layers a project configures a model for. The dashboard reads this rather than holding a copy of the list, so a layer added server-side appears in the UI with no front-end release.

curl http://localhost:8080/api/v1/llm/layers
{
"data": {
"version": 1,
"default_layer_key": "default",
"layers": [
{
"key": "exploration",
"label": "Exploration",
"description": "The discovery agent's autonomous exploration of the warehouse.",
"group": "main",
"refuses_reasoning_models": false
},
{
"key": "table_descriptions",
"label": "Table descriptions",
"description": "The searchable description written for each table during schema indexing.",
"group": "small",
"refuses_reasoning_models": true
}
]
}
}

version is the configuration generation a project must be configured at. refuses_reasoning_models is the layer's pre-flight rule, sent as data so the model picker can warn before a save the API would reject.

GET /api/v1/projects/{id}/llm/config​

The project's per-layer configuration, as the settings page should render it.

For a project that has never been configured, the values are seeded from its legacy llm / blurb_llm fields and seeded is true — which is what makes the forced trip through settings a review rather than a seventeen-row data-entry exercise. The seed lives here rather than in the project document so a client that round-trips GET /projects/{id} into a PUT cannot silently configure the project and skip that review.

curl http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/llm/config
{
"data": {
"configured": false,
"version": 0,
"required_version": 1,
"seeded": true,
"providers": [{"id": "default", "provider": "bedrock", "config": {"region": "us-east-1"}}],
"layer_models": {"default": {"provider_id": "default", "model": "us.anthropic.claude-opus-4-8-v1", "config": {"prompt_cache": "true"}}}
}
}

GET /api/v1/providers/llm​

List registered LLM providers with metadata and config fields.

curl http://localhost:8080/api/v1/providers/llm
{
"data": [
{
"id": "claude",
"name": "Claude (Anthropic)",
"description": "Anthropic Claude API - direct access",
"config_fields": [
{"key": "model", "label": "Model", "required": true, "type": "string", "default": "claude-sonnet-4-6"}
],
"auth_methods": [
{
"id": "api_key",
"name": "API Key",
"description": "Anthropic Claude API key.",
"fields": [
{"key": "credentials_json", "label": "API Key", "required": true, "type": "credential", "placeholder": "sk-ant-..."}
]
}
],
"models": [
{"id": "claude-opus-4-7", "display_name": "Claude Opus 4.7", "wire": "anthropic", "max_output_tokens": 128000, "pricing": {"input_per_million": 5.0, "output_per_million": 25.0}},
{"id": "claude-sonnet-4-6","display_name": "Claude Sonnet 4.6","wire": "anthropic", "max_output_tokens": 64000, "pricing": {"input_per_million": 3.0, "output_per_million": 15.0}},
{"id": "claude-haiku-4-5", "display_name": "Claude Haiku 4.5", "wire": "anthropic", "max_output_tokens": 64000, "pricing": {"input_per_million": 1.0, "output_per_million": 5.0}}
],
"default_max_output_tokens": 16384,
"supports_tools": true
}
]
}

GET /api/v1/projects/{id}/llm/extended-models​

List project-scoped LLM model entries contributed by any registered external model registry (see Plugin hooks: External model registry). Returns an empty array when no external model registry extender is registered. Each entry has the same shape as a model row inside GET /api/v1/providers/llm, so the dashboard's model picker can merge both lists without reshaping.

curl http://localhost:8080/api/v1/projects/proj-123/llm/extended-models
{
"data": [
{
"id": "ext:my-custom-model",
"display_name": "My Custom Model",
"wire": "openai-compat",
"max_output_tokens": 4096,
"max_input_tokens": 32000,
"input_price_per_million": 1.5,
"output_price_per_million": 7.5
}
]
}

GET /api/v1/providers/warehouse​

List registered warehouse providers with metadata and config fields.

curl http://localhost:8080/api/v1/providers/warehouse
{
"data": [
{
"id": "bigquery",
"name": "Google BigQuery",
"description": "Google Cloud BigQuery data warehouse",
"dialect": "BigQuery Standard SQL",
"config_fields": [
{"key": "project_id", "label": "GCP Project ID", "required": true, "type": "string"},
{"key": "location", "label": "Location", "type": "string", "default": "US"},
{"key": "dataset", "label": "Default Dataset", "type": "string"}
]
}
]
}

Domains​

GET /api/v1/domains​

List available domains with their categories.

curl http://localhost:8080/api/v1/domains
{
"data": [
{
"id": "gaming",
"categories": [
{"id": "match3", "name": "Match-3", "description": "Puzzle games with match-3 mechanics (e.g., Candy Crush, Homescapes)"},
{"id": "idle", "name": "Idle / Incremental", "description": "Games focused on resource accumulation, prestige cycles, and offline progression"},
{"id": "casual", "name": "Casual / Hyper-Casual", "description": "Simple, accessible games with short sessions and broad appeal, often ad-monetized"}
]
},
{
"id": "social",
"categories": [
{"id": "content_sharing", "name": "Content Sharing", "description": "Platforms focused on creating and sharing content — photos, videos, stories"}
]
}
]
}

GET /api/v1/domains/{domain}/categories/{category}/schema​

Get profile JSON Schema for a domain/category. Used by the dashboard to render dynamic forms.

curl http://localhost:8080/api/v1/domains/gaming/categories/match3/schema

Returns a JSON Schema object defining the profile fields (basic_info, gameplay, monetization, boosters, etc.).

GET /api/v1/domains/{domain}/categories/{category}/areas​

Get analysis areas for a domain/category.

curl http://localhost:8080/api/v1/domains/gaming/categories/match3/areas
{
"data": [
{"id": "churn", "name": "Churn Risks", "description": "Players at risk of leaving the game", "keywords": ["churn", "retention", "cohort"], "is_base": true, "priority": 1},
{"id": "engagement", "name": "Engagement Patterns", "is_base": true, "priority": 2},
{"id": "monetization", "name": "Monetization Opportunities", "is_base": true, "priority": 3},
{"id": "levels", "name": "Level Difficulty", "is_base": false, "priority": 4},
{"id": "boosters", "name": "Booster Usage", "is_base": false, "priority": 5}
]
}

Projects​

POST /api/v1/projects​

Create a new project.

curl -X POST http://localhost:8080/api/v1/projects \
-H "Content-Type: application/json" \
-d '{
"name": "Puzzle Quest Analytics",
"description": "Match-3 puzzle game analytics",
"domain": "gaming",
"category": "match3",
"warehouse": {
"provider": "bigquery",
"project_id": "my-gcp-project",
"datasets": ["analytics_data", "features"],
"location": "US",
"filter_field": "app_id",
"filter_value": "my-app-123"
},
"llm": {
"provider": "claude",
"model": "claude-sonnet-4-6",
"config": {}
}
}'
{
"data": {
"id": "507f1f77bcf86cd799439011",
"name": "Puzzle Quest Analytics",
"domain": "gaming",
"category": "match3",
"status": "active",
"warehouse": { "provider": "bigquery", "datasets": ["analytics_data"], "location": "US", "config": { /* ... */ } },
"llm": { "provider": "claude", "model": "claude-sonnet-4-6" }
}
}

The full saved project is echoed back in data. Domain pack prompts are automatically seeded into the project on creation.

GET /api/v1/projects​

List all projects.

curl http://localhost:8080/api/v1/projects
{
"data": [
{
"id": "507f1f77bcf86cd799439011",
"name": "Puzzle Quest Analytics",
"domain": "gaming",
"category": "match3",
"status": "active",
"last_run_status": "completed",
"last_run_at": "2026-03-14T10:30:00Z",
"last_run_completed_at": "2026-03-14T10:42:00Z"
}
]
}

Each project carries a summary of its most recent discovery run, derived from the latest discovery_runs record at read time:

  • last_run_status — the run's lifecycle status: pending, running, completed, failed, or cancelled. Omitted when the project has never started a run.
  • last_run_at — when the most recent run started (RFC 3339). Clients count elapsed time up from this while a run is in progress.
  • last_run_completed_at — when it finished (RFC 3339). Omitted while the run is still in progress or never completed.

GET /api/v1/projects/{id}​

Get a project with full configuration.

curl http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011

Returns the complete project object including warehouse, LLM, and profile configuration, plus the same last_run_status / last_run_at / last_run_completed_at run summary as the list endpoint.

PUT /api/v1/projects/{id}​

Update a project. Supports partial updates — only fields present in the request body are updated. Prompts and profile are preserved if not included.

curl -X PUT http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011 \
-H "Content-Type: application/json" \
-d '{
"name": "Puzzle Quest (Updated)"
}'

DELETE /api/v1/projects/{id}​

Delete a project and all its data (discoveries, feedback, secrets, prompts).

curl -X DELETE http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011

Schema index​

GET /api/v1/projects/{id}/schema-index/runs​

Return the durable per-datasource schema-index run history, newest finished_at first. Each record is stamped when a datasource finishes indexing (success or failure) and survives the next run — unlike the live, project-level schema-index/status.

Query parameters (both optional):

  • datasource_id — filter to one data source; omit to list every data source's runs.
  • limit — cap the number of records (default 50, max 200).
curl "http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/schema-index/runs?datasource_id=wh_redshift&limit=20"
{
"data": {
"runs": [
{
"datasource_id": "wh_redshift",
"datasource_name": "Redshift — Sales",
"run_id": "20260911T142000.000Z",
"kind": "tables",
"objects_indexed": 42,
"blurbs_generated": 42,
"status": "ready",
"phase_durations": { "schema_discovery": 3200, "describing_tables": 12800, "embedding": 540 },
"tokens_in": 10240,
"tokens_out": 5120,
"started_at": "2026-09-11T14:19:40Z",
"finished_at": "2026-09-11T14:20:00Z"
}
]
}
}

A failed run carries the same shape with "status": "failed" and an "error" string.


Schema editor​

Advanced, hand-editing of the indexed schema. Reads require viewer; edits and deletions require member. Manual edits are ephemeral — a re-index re-discovers the schema from the warehouse and discards them (Clear schema cache discards them too, re-discovering on the next re-index) — but every edit is recorded in a durable audit trail, and since_last_index powers the "N manual edits will be lost" warning shown before a re-index / cache clear.

GET /api/v1/projects/{id}/schema-editor/tables​

List a datasource's indexed tables — structure (columns) from the Mongo schema cache joined with the blurb + keywords from Qdrant. Sample data is omitted.

Query parameters (all optional):

  • datasource_id — the data source to browse (empty resolves to the primary).
  • search — case-insensitive substring filter on the table name.
  • limit — cap the number of tables returned (default 200, max 1000). truncated is true when more matched than were returned.
curl "http://localhost:8080/api/v1/projects/507f.../schema-editor/tables?datasource_id=wh_redshift&search=orders"
{
"data": {
"tables": [
{
"table": "public.orders",
"row_count": 128394,
"columns": [{ "name": "id", "type": "int", "nullable": false, "category": "primary_key" }],
"blurb": "One row per customer order.",
"keywords": ["orders", "revenue"],
"has_blurb": true,
"embedding_model": "openai/text-embedding-3-large"
}
],
"total": 1,
"truncated": false,
"datasource_id": "wh_redshift"
}
}

PUT /api/v1/projects/{id}/schema-editor/tables?datasource_id=&table=​

Apply an edit to one table. table is the qualified name (query param). The body carries only the fields being changed; columns is the set to keep (removal only — the server intersects it by name with the existing columns). A blurb change re-embeds and replaces the Qdrant point; keyword/column changes are payload-only.

curl -X PUT "http://localhost:8080/api/v1/projects/507f.../schema-editor/tables?table=public.orders" \
-H 'Content-Type: application/json' \
-d '{"blurb":"One row per customer order.","columns":[{"name":"id"},{"name":"total"}]}'

Returns the updated table view (same shape as a list entry). 412 when a blurb edit is requested but the project has no embedding provider configured.

DELETE /api/v1/projects/{id}/schema-editor/tables?datasource_id=&table=​

Remove a table from the index — its Qdrant blurb point and its Mongo cache row.

curl -X DELETE "http://localhost:8080/api/v1/projects/507f.../schema-editor/tables?table=public.orders"

GET /api/v1/projects/{id}/schema-editor/edits​

The manual-edit audit trail (newest first) plus an "edits at risk" counter, since_last_index: the number of manual edits — of every kind (blurb, keywords, column removal, table delete) — made since the latest indexing run. That is what a re-index or a Clear schema cache will discard, because both re-discover the schema from the warehouse. The counter (and the edits list) is scoped to datasource_id when it is supplied, else project-wide.

curl "http://localhost:8080/api/v1/projects/507f.../schema-editor/edits?datasource_id=wh_redshift"
{
"data": {
"edits": [
{
"project_id": "507f...",
"datasource_id": "wh_redshift",
"table": "public.orders",
"action": "blurb_edit",
"before": "Orders.",
"after": "One row per customer order.",
"actor": "[email protected]",
"at": "2026-09-17T14:20:00Z"
}
],
"since_last_index": 1
}
}

Prompts​

GET /api/v1/projects/{id}/prompts​

Get the project's editable prompts. These are copies of the domain pack defaults, customizable per-project.

curl http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/prompts
{
"data": {
"exploration": "# Gaming Analytics Discovery Agent\n\nYou are an autonomous...",
"recommendations": "# Generate Actionable Recommendations\n\n...",
"base_context": "## Project Profile\n\n{{PROFILE}}\n\n...",
"analysis_areas": {
"churn": {
"name": "Churn Risks",
"description": "Players at risk of leaving the game",
"keywords": ["churn", "retention"],
"prompt": "# Churn Pattern Analysis\n\n...",
"is_base": true,
"enabled": true,
"priority": 1
}
}
}
}

PUT /api/v1/projects/{id}/prompts​

Update prompts. Any field can be updated independently.

curl -X PUT http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/prompts \
-H "Content-Type: application/json" \
-d '{
"base_context": "## Updated Project Profile\n\n{{PROFILE}}\n\n...",
"analysis_areas": {
"churn": {
"name": "Churn Risks",
"prompt": "# Updated Churn Analysis\n\n...",
"enabled": true
}
}
}'

Discoveries​

POST /api/v1/projects/{id}/discover​

Trigger a discovery run. Spawns the agent as a subprocess or K8s Job.

Request body (all fields optional):

FieldTypeDefaultDescription
areasstring[](all)Selective discovery — run only these analysis areas. Empty/omitted = all areas.
max_stepsint100Maximum exploration steps.
min_stepsintfloor(0.6 * max_steps)Floor on exploration steps before the agent will accept a done signal from the LLM. 0 disables the floor. Recommended for reasoning models (Qwen3, DeepSeek-R1, GPT-OSS) that tend to terminate exploration too early. Must be in [0, max_steps] — values outside that range return 400.

Returns 409 when the project's LLM models have not been configured, with a message naming its settings page. This is the shared trigger seam, so the run button, this endpoint and scheduled runs all hit the same check — a schedule on an unconfigured project fails until someone visits settings, and records that reason in its run history. Managed deployments are never gated.

# Run all areas with default steps and the 60%-of-max_steps floor
curl -X POST http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/discover

# Run specific areas with custom step count
curl -X POST http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/discover \
-H "Content-Type: application/json" \
-d '{"areas": ["churn", "monetization"], "max_steps": 50}'

# Force a stricter floor for a reasoning model (e.g., Qwen3 on Bedrock)
curl -X POST http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/discover \
-H "Content-Type: application/json" \
-d '{"max_steps": 100, "min_steps": 80}'

# Disable the floor entirely
curl -X POST http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/discover \
-H "Content-Type: application/json" \
-d '{"max_steps": 100, "min_steps": 0}'
{
"status": "started",
"run_id": "507f1f77bcf86cd799439012",
"message": "Discovery agent started"
}

Returns 409 Conflict if a discovery is already running for this project. Returns 400 Bad Request if min_steps is negative or exceeds max_steps.

GET /api/v1/projects/{id}/discoveries​

List all discoveries for a project (newest first). Excludes heavy log fields for performance.

curl http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/discoveries
{
"data": [
{
"id": "507f1f77bcf86cd799439013",
"project_id": "507f1f77bcf86cd799439011",
"run_type": "full",
"discovery_date": "2026-03-14T10:30:00Z",
"total_steps": 42,
"duration": 480000000000,
"insights": [...],
"recommendations": [...],
"summary": {"total_insights": 7, "total_recommendations": 5, "queries_executed": 42}
}
]
}

GET /api/v1/discoveries/{id}​

Get a single discovery with full data (including exploration log, analysis log, validation log).

curl http://localhost:8080/api/v1/discoveries/507f1f77bcf86cd799439013

Returns the complete DiscoveryResult including all logs.

GET /api/v1/projects/{id}/discoveries/latest​

Get the most recent discovery for a project.

GET /api/v1/projects/{id}/discoveries/{date}​

Get a discovery by date (format: YYYY-MM-DD).

GET /api/v1/projects/{id}/status​

Get the current discovery status for a project (running, completed, or null).

curl http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/status
{
"data": {
"run": {
"id": "507f1f77bcf86cd799439012",
"status": "running",
"phase": "exploration",
"progress": 45,
"total_queries": 22,
"insights_found": 3,
"steps": [...]
}
}
}

Runs​

GET /api/v1/runs/{runId}​

Get live discovery run status with step-by-step progress.

curl http://localhost:8080/api/v1/runs/507f1f77bcf86cd799439012
{
"data": {
"id": "507f1f77bcf86cd799439012",
"project_id": "507f1f77bcf86cd799439011",
"status": "running",
"phase": "exploration",
"phase_detail": "Step 22/100",
"progress": 45,
"started_at": "2026-03-14T10:30:00Z",
"updated_at": "2026-03-14T10:35:00Z",
"steps": [
{
"phase": "exploration",
"step_num": 1,
"timestamp": "2026-03-14T10:30:05Z",
"type": "query",
"message": "Checking retention rates...",
"llm_thinking": "Let me start by looking at retention cohorts...",
"query": "SELECT cohort_date, day_1_retention FROM ...",
"row_count": 30,
"query_time_ms": 450
}
],
"total_queries": 22,
"successful_queries": 21,
"failed_queries": 1,
"insights_found": 3,
"attempt": 2,
"last_checkpoint_step": 42,
"active_ms": 185000,
"last_resumed_at": "2026-03-14T10:34:00Z",
"max_steps": 100,
"min_steps": 60,
"lifecycle": [
{"status": "pending", "at": "2026-03-14T09:10:00Z", "reason": "manual", "attempt": 1},
{"status": "failed", "at": "2026-03-14T10:02:00Z", "reason": "agent exited: signal: killed", "attempt": 1,
"llm_provider": "claude", "llm_model": "claude-sonnet-5-5"},
{"status": "running", "at": "2026-03-14T10:34:00Z", "reason": "resumed from the last exploration checkpoint", "attempt": 2}
]
}
}

The resume-related fields are all optional and absent on runs that predate checkpointing:

FieldMeaning
attemptHow many times the run has been started. 1 on create, incremented by each resume.
last_checkpoint_stepHighest exploration step with a checkpoint. > 0 on a failed run is what makes it resumable; zeroed on success.
active_msCumulative active compute across attempts, so elapsed time excludes the hours a failed run sat waiting to be noticed. Best-effort: an attempt killed before its terminal write contributes nothing.
last_resumed_atWhen the latest attempt was requested.
max_steps, min_steps, areas, effortThe run's own parameters, so a resume replays the budget the caller chose.
lifecycleAppend-only transition log. Each attempt's terminal event carries the LLM provider and model that served it.

POST /api/v1/runs/{runId}/resume​

Resume a failed discovery run from its last exploration checkpoint, re-entering the same run id. The agent replays the steps already executed — no warehouse re-queries and no exploration LLM calls for them — and continues from the next one. A run that died after exploration finished goes straight to analysis, and restores the analysis areas it had already completed rather than re-running them; an area whose analysis had failed is retried.

Requires the member role, matching POST /api/v1/projects/{id}/discover.

curl -X POST http://localhost:8080/api/v1/runs/507f1f77bcf86cd799439012/resume
{"data": {"status": "resumed", "run_id": "507f1f77bcf86cd799439012", "attempt": 2}}

Refusals — all of these are real answers, not errors to retry blindly:

StatusWhen
404No such run, or the project it belongs to is gone.
409The run is not failed. Only a failed run is resumable — cancelled is a deliberate hard kill and stays terminal, completed has nothing left, and running / pending already have an agent. The message names the run's actual status.
409No checkpoint to resume from: it expired (see DISCOVERY_CHECKPOINT_RETENTION) or the run died before its first step. Start a new run.
409The project fails a precondition a fresh run would also fail — its schema index is not ready, or a plugin owns its lifecycle state. A resume re-enters exploration, so it needs the same guarantees.
409Another discovery run is already in progress for the project.
409The run stopped being resumable between the check and the flip — another request got there first.
500The agent could not be spawned. The run is left failed with its checkpoints intact, so it is resumable again.

Resume does not meter, charge, refund, or open a plan reservation: the run's charge is keyed on its run id and a resume re-enters that id, so it is free by construction.

DELETE /api/v1/runs/{runId}​

Cancel a running discovery. Kills the agent process and updates the run status.

Cancellation is terminal: the run's exploration checkpoints are deleted and it cannot be resumed.

curl -X DELETE http://localhost:8080/api/v1/runs/507f1f77bcf86cd799439012
{"status": "cancelled"}

Feedback​

POST /api/v1/discoveries/{runId}/feedback​

Submit feedback on an insight, recommendation, or exploration step.

curl -X POST http://localhost:8080/api/v1/discoveries/507f1f77bcf86cd799439013/feedback \
-H "Content-Type: application/json" \
-d '{
"target_type": "insight",
"target_id": "churn-1",
"rating": "like"
}'
# Dislike with comment
curl -X POST http://localhost:8080/api/v1/discoveries/507f1f77bcf86cd799439013/feedback \
-H "Content-Type: application/json" \
-d '{
"target_type": "insight",
"target_id": "churn-2",
"rating": "dislike",
"comment": "This metric definition is wrong for our game"
}'
FieldRequiredValuesDescription
target_typeYesinsight, recommendation, exploration_stepWhat is being rated
target_idYesstringID of the target (insight ID, recommendation index, step number)
ratingYeslike, dislikeThe rating
commentNostringOptional comment (typically with dislikes)

Feedback is upserted — one rating per (discovery, target_type, target_id). Submitting again replaces the previous rating.

GET /api/v1/discoveries/{runId}/feedback​

List all feedback for a discovery.

curl http://localhost:8080/api/v1/discoveries/507f1f77bcf86cd799439013/feedback

DELETE /api/v1/feedback/{id}​

Delete a feedback entry.

curl -X DELETE http://localhost:8080/api/v1/feedback/507f1f77bcf86cd799439014

Pricing​

GET /api/v1/pricing​

Get current LLM and warehouse pricing configuration. Auto-seeded from provider defaults on first startup.

curl http://localhost:8080/api/v1/pricing

PUT /api/v1/pricing​

Update pricing (e.g., if your negotiated rates differ from defaults).

curl -X PUT http://localhost:8080/api/v1/pricing \
-H "Content-Type: application/json" \
-d '{
"llm": {
"claude-sonnet-4": {"input_per_million": 2.5, "output_per_million": 12.0}
}
}'

Connection Testing​

POST /api/v1/projects/{id}/test/warehouse​

Test the warehouse connection for a project. Spawns the agent with --test-connection warehouse to validate using the agent's IAM context.

curl -X POST http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/test/warehouse
{
"data": {
"success": true,
"provider": "bigquery",
"datasets": ["events_prod"]
}
}

On failure:

{
"data": {
"success": false,
"error": "bigquery: cannot access dataset events_prod: googleapi: Error 403: Access Denied"
}
}

POST /api/v1/projects/{id}/test/llm[?layer=]​

Test an LLM connection for a project. Spawns the agent with --test-connection llm to validate credentials and model access.

layer picks one layer's provider entry and model. Omitted — or set to default — tests the project's default layer, which is what this endpoint has always done. The --layer flag is then omitted from the agent invocation, so a per-layer test against an agent image older than the API degrades to the default rather than failing on an unknown flag. An unknown layer key returns 400 here rather than reaching the agent.

# the default layer
curl -X POST http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/test/llm

# one layer's own entry and model
curl -X POST 'http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/test/llm?layer=exploration'
{
"data": {
"success": true,
"layer": "exploration",
"provider_id": "deepinfra",
"provider": "litellm",
"model": "deepseek-v4.1-flash"
}
}

Secrets​

PUT /api/v1/projects/{id}/secrets/{key}​

Create or update a per-project secret. The value is encrypted at rest.

# Set LLM API key
curl -X PUT http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/secrets/llm-api-key \
-H "Content-Type: application/json" \
-d '{"value": "sk-ant-api03-..."}'

GET /api/v1/projects/{id}/secrets​

List secrets for a project. Returns masked values only — full values are never exposed via the API.

curl http://localhost:8080/api/v1/projects/507f1f77bcf86cd799439011/secrets
{
"data": [
{
"key": "llm-api-key",
"masked": "sk-ant***DwAA",
"updated_at": "2026-03-14T10:00:00Z"
}
]
}

Note: There is no DELETE endpoint for secrets. Secrets are removed manually via cloud console, CLI, or direct database access. This is intentional to prevent accidental deletion.


Bookmark Lists​

Named collections of insights and recommendations. Every list and bookmark is scoped by (project_id, user_id) where user_id comes from the authenticated principal — "anonymous" under the default no-auth setup.

POST /api/v1/projects/{id}/lists​

Create a bookmark list.

Request body:

{"name": "Retention ideas", "description": "optional", "color": "#2b7"}

Response 201:

{
"id": "...",
"project_id": "...",
"user_id": "anonymous",
"name": "Retention ideas",
"description": "optional",
"color": "#2b7",
"item_count": 0,
"created_at": "2026-04-17T…",
"updated_at": "2026-04-17T…"
}

Returns 400 if name is empty or longer than 200 characters.

GET /api/v1/projects/{id}/lists​

List the caller's lists for this project, newest-updated first. Each entry includes item_count.

GET /api/v1/projects/{id}/lists/{listId}​

List detail. Response includes an items array where each entry is:

{
"bookmark": {"id": "...", "target_type": "insight", "target_id": "...", ...},
"target": { /* full insight or recommendation document */ }
}

If a bookmarked target has been deleted from the source collection, target is omitted and deleted: true is set — the dashboard renders these as "[removed]" placeholders.

Returns 404 if the list does not exist or is not owned by the caller.

PATCH /api/v1/projects/{id}/lists/{listId}​

Partial update. Only provided fields change; created_at is never touched.

{"name": "New name"}

Returns 400 on empty name, 404 on wrong owner.

DELETE /api/v1/projects/{id}/lists/{listId}​

Deletes the list and cascades to every bookmark in it. The underlying insights and recommendations are untouched. Returns 404 on wrong owner.

POST /api/v1/projects/{id}/lists/{listId}/items​

Add a bookmark. Idempotent — calling with the same (target_type, target_id) that already exists in the list returns the existing bookmark.

{"target_type": "insight", "target_id": "...", "discovery_id": "...", "note": "optional"}

target_type must be "insight" or "recommendation". Returns 404 if the list is not owned by the caller.

DELETE /api/v1/projects/{id}/lists/{listId}/items/{bookmarkId}​

Remove a bookmark. Returns 404 if the bookmark is in a different list or not owned by the caller.

GET /api/v1/projects/{id}/bookmarks?target_type=insight&target_id=...​

Reverse lookup used by the dashboard's Add-to-list menu. Returns an array of list IDs (scoped to the caller) that currently contain the given target. Empty array when the target is not bookmarked.


Read Marks​

Per-user state for which insights and recommendations the caller has already opened.

POST /api/v1/projects/{id}/reads​

Mark a target read. Idempotent — repeated calls refresh read_at without creating duplicates, enforced by a unique compound index on (project_id, user_id, target_type, target_id).

{"target_type": "insight", "target_id": "..."}

DELETE /api/v1/projects/{id}/reads​

Mark a target unread. Same body shape as above. Idempotent: returns 200 whether or not a mark existed.

GET /api/v1/projects/{id}/reads?target_type=insight​

Returns the target_ids the caller has read, as a flat array of strings. List pages use this to apply greyed-out styling to read rows without fetching full mark documents.

["ins-1", "ins-2", "ins-5"]

Error Responses​

All error responses follow the same format:

{"error": "descriptive error message"}

Common HTTP status codes:

StatusMeaning
200Success
201Created
202Accepted (async operation started)
400Bad request (invalid input)
404Not found
409Conflict (e.g., discovery already running)
500Internal server error

Next Steps​