REST API
Valved is headless by default. valved serve exposes a FastAPI REST API covering the core loop (plan, build, deploy, run) and the control plane (jobs, workers, pipelines, schedules, tokens, webhooks, metrics, memory), so an external agent, chat tool, or script can drive Valved without the CLI.
It is not a mirror of the CLI. Project setup — init, connect, auth, creating or changing targets, registering an MCP server, el run — has no REST equivalent, and the routers for targets, agents, skills and MCP servers are read-only. Components and pipeline definitions are not: both are registered, patched and deregistered over REST, because in a container deployment the API is the only way to change them. POST /plans accepts only {goal, pipeline_name}, so plan --refine and the destination pre-seeds have no endpoint either.
valved serve [--workers N] [--interval S]# binds from [api] host/port in valved/api.toml — default http://127.0.0.1:8765Discovering the surface
Section titled “Discovering the surface”The OpenAPI schema is served live, and there’s interactive Swagger:
curl -s http://127.0.0.1:8765/api/openapi.json # machine-readable schemaopen http://127.0.0.1:8765/api/docs # Swagger UIThe MCP server is generated from exactly this schema, so the REST surface and the MCP tool catalog are always in lockstep.
Health
Section titled “Health”curl -s http://127.0.0.1:8765/healthz # livenesscurl -s http://127.0.0.1:8765/readyz # readiness (200 once migrations are at head)Resource routers
Section titled “Resource routers”The API is organized into routers under /api/v1, mirroring the CLI groups:
| Router | Resource |
|---|---|
plans |
draft plans (create, refine, show) |
builds |
build runs against a plan |
pipelines |
pipeline definitions (register, batch register, patch, deregister), tags, per-step durations, lineage |
runs |
on-demand runs, logs, the event stream, a run’s step rows, and a dbt step’s model graph |
schedules |
live schedule (pause/resume/set-cron/delete), the upcoming-run calendar, audited changes |
deploys |
promote built code via the configurable handoff |
jobs / workers |
the queue and worker pool |
asks / investigations |
read-only Q&A and recovery records |
components / targets |
components (register, patch, deregister, sync, last synced revision) and connection targets |
agents / skills / mcp_servers |
extensibility |
memory / metrics |
project memory and cost/usage rollups |
tokens / webhooks |
API token lifecycle and run-completion webhooks |
The schedule calendar
Section titled “The schedule calendar”GET /api/v1/schedules/calendar expands every live cron into one entry per firing across a window — the next 24 hours by default:
curl -s "http://127.0.0.1:8765/api/v1/schedules/calendar" -H "Authorization: Bearer $VALVED_API_TOKEN"curl -s "http://127.0.0.1:8765/api/v1/schedules/calendar?from=2026-09-04T00:00:00Z&to=2026-09-06T00:00:00Z"This is the question GET /api/v1/schedules cannot answer: that lists the schedules and each one’s single next_fires_at, which stops being enough the moment a pipeline runs more than once in the window you care about.
Each cron is evaluated in its own schedule’s timezone, so a window crossing a DST boundary yields the correct wall-clock firings; instants come back in UTC, ascending. Both bounds are inclusive. Paused schedules are excluded (?include_paused=true returns them flagged), a schedule whose cron cannot be expanded is reported in problems rather than silently dropped, and a window over 366 days or with to at or before from is a 400.
It is cron arithmetic, not a prediction — it says when the expression matches, not whether a worker will be free or whether the scheduler has fallen behind.
Requests carry a bearer token. valved serve bootstraps one to .valved/token on first run; mint or rotate with:
valved auth rotate # prints the new token once# then:curl -s http://127.0.0.1:8765/api/v1/pipelines \ -H "Authorization: Bearer $VALVED_API_TOKEN"Programmatic token lifecycle is REST-native: POST /api/v1/tokens and DELETE /api/v1/tokens/{id}.
GET /api/v1/me names the caller the control plane resolved. Users are provisioned just-in-time — the first request is the provisioning event — and the record is attribution, not an account: no role, no scope list, nothing a client can branch on to decide what it may do. The deployment’s posture remains the authorization boundary.
Runs and jobs record who triggered them, so “who ran this, and when” is answerable from the run itself rather than by correlating logs.
Cross-origin write protection
Section titled “Cross-origin write protection”[api] trusted_origins names the page origins allowed to drive a mutating request (POST/PUT/PATCH/DELETE). It is empty by default, which changes nothing for existing deployments; set it and any other origin’s write is refused.
* is not accepted — an allowlist matching every origin is not an allowlist — and entries must be a bare origin (scheme://host[:port], no path). This is what makes a cookie credential safe to issue, which is why the web console’s session path refuses to start with an empty allowlist.
Pagination and search
Section titled “Pagination and search”Collection endpoints page with a keyset cursor and return an envelope:
{ "items": [], "next_cursor": "…", "has_more": true, "total_count": 137 }Pass ?limit= (default 50, capped at 200) and ?cursor= to walk it. next_cursor is present exactly when a further page exists.
total_count is reported only when it is exact. A listing router materializes its filtered candidates before paginating, so counting them is free — but only while it did not hit its ceiling. Past that the candidate list is a window rather than the collection, and the field comes back null. So null means “more than this page, and I will not guess” — never zero.
Paginated collections accept ?q= for server-side search across the whole collection, not just the page in hand. Endpoints that return their collection whole — the small, bounded ones — have no cursor and need no q.
One trap worth naming: FastAPI silently ignores an unknown query parameter. A misspelled filter narrows nothing while appearing to work, so check parameter names against /api/openapi.json rather than assuming a 400 will tell you.
Three endpoints back the explorer’s read-only Q&A: GET /api/v1/asks (cursor-paginated history, filterable by ?pipeline= and ?since=), GET /api/v1/asks/{ask_id} (one ask, with its full citations hydrated from the durable trace), and POST /api/v1/asks, which runs the explorer synchronously and returns the completed ask — 5–30 seconds is typical.
POST /api/v1/asks takes three body fields:
| Field | Type | Required | Meaning |
|---|---|---|---|
question |
string | yes | The question to answer. |
pipeline |
string | no | Scope the investigation to one pipeline. |
target |
string | no | The target whose warehouse connection backs the explorer’s sql tool. |
curl -s "$BASE/asks" -H "$AUTH" -H 'Content-Type: application/json' \ -d '{"question":"what breaks if I change raw_stripe.charges?"}'target is not just a label on the ask row — it is resolved into the connection the explorer actually queries, so an ask over REST reaches your warehouse exactly as valved ask does (see the core loop). Because it now reaches target resolution, it is validated against the target-name regex ^[a-z][a-z0-9_]*$: a free-form label that earlier releases would have stored returns 400 with type https://valved.dev/errors/target-resolution. Values that follow the normal target-name rules are unaffected. Omit the field and Valved resolves VALVED_TARGET, then default_target — an unscoped ask still queries the project’s default target.
A failed ask is not an HTTP error. If the explorer cannot answer, POST /api/v1/asks still returns 200 with "status": "failed" and an error_message — check the body’s status, not the HTTP code.
Errors
Section titled “Errors”Every error is RFC 9457 application/problem+json: {type, title, status, detail, instance} plus endpoint-specific fields. type is https://valved.dev/errors/<slug>; switch on the slug, never on title or detail. The slugs you’ll meet on the asks endpoints:
| Status | Slug | When |
|---|---|---|
| 400 | target-resolution |
target fails the target-name regex (POST /asks). |
| 400 | bad-request |
A malformed ?since= window on GET /asks. |
| 401 | missing-bearer-token / invalid-token |
No Authorization: Bearer header, or an unrecognized token. |
| 404 | not-found |
GET /asks/{ask_id} for an unknown or non-UUID id. |
| 422 | validation |
Body failed schema validation — e.g. question omitted. |
Other routers surface their own slugs the same way — POST /builds returns 409 config-drift (with plan_id, expected_config_hash, actual_config_hash and a recovery_hint in the body) or 409 plan-expired; POST /plans returns 422 plan-generation. An unrecognized internal failure becomes a 500 internal with an opaque detail; the stack trace is logged server-side, never returned.
One slug is worth special mention because it is the one you should retry: 503 lock-timeout means the request gave up waiting for a row another transaction was holding. It is transient contention, not a fault — the same request will usually succeed on a retry. Valved bounds this wait deliberately (lock_timeout in the [state_store] config section, 5s by default) so contention fails fast and says what it was, rather than hanging until a proxy in front returns an opaque gateway timeout. The detail carries the server’s own message.
Pace the retry off the response’s Retry-After header (seconds) rather than retrying immediately, and add your own jitter on top. Retrying a lock timeout with no delay puts you straight back into the queue for the row you just lost, alongside every other client doing the same — the header is there so contention sheds instead of amplifying. 503 idle-transaction-timeout carries the same header and means the same thing from the other side: the server reaped a transaction that had gone idle, and a fresh request gets a fresh session.
Streaming & webhooks
Section titled “Streaming & webhooks”-
Run event stream —
GET /api/v1/runs/{run_id}/streamstreams live run events, ending with the run’s terminal event. (valved run --watchdoes not use it: it reads the local state store directly.) Because it’s a stream, it is not exposed as an MCP tool. -
Webhooks — register a webhook to receive run-completion callbacks; manage them under
/api/v1/webhooks(create, patch, rotate-secret, delete). Each delivery is{"event", "timestamp", "data"}. A run that finished with warnings is delivered asrun.succeededwithdata.statusset to"warning"—"success"for a clean one — so a subscriber counting successes counts it. -
Slack — create a webhook with
"format": "slack"and a Slack Incoming Webhook URL, and each event is posted as a readable message instead of the envelope: the pipeline, the outcome, the target, how long it ran, and the error (secrets scrubbed, cut to what Slack accepts). With noevent_filtersa Slack subscription getsrun.failedonly — including a run whose worker died. Set[api] public_urlinvalved.tomlto the address people reach Valved at, and each message carries an Open run button. The Slack URL is the channel’s credential, so the API never shows it back whole.Terminal window curl -X POST "$VALVED_URL/api/v1/webhooks" -H 'Content-Type: application/json' \-d '{"url": "https://hooks.slack.com/services/T…/B…/…", "format": "slack"}'
Example: plan → build → run over HTTP
Section titled “Example: plan → build → run over HTTP”BASE=http://127.0.0.1:8765/api/v1AUTH="Authorization: Bearer $VALVED_API_TOKEN"
PLAN=$(curl -s "$BASE/plans" -H "$AUTH" -H 'Content-Type: application/json' \ -d '{"goal":"ingest the Stripe charges API into raw_stripe"}' | jq -r .id)
curl -s "$BASE/builds" -H "$AUTH" -H 'Content-Type: application/json' \ -d "{\"plan_id\":\"$PLAN\"}"
curl -s "$BASE/runs" -H "$AUTH" -H 'Content-Type: application/json' \ -d '{"pipeline":"stripe_charges"}'