Skip to content

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:8765

The OpenAPI schema is served live, and there’s interactive Swagger:

Terminal window
curl -s http://127.0.0.1:8765/api/openapi.json # machine-readable schema
open http://127.0.0.1:8765/api/docs # Swagger UI

The MCP server is generated from exactly this schema, so the REST surface and the MCP tool catalog are always in lockstep.

Terminal window
curl -s http://127.0.0.1:8765/healthz # liveness
curl -s http://127.0.0.1:8765/readyz # readiness (200 once migrations are at head)

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

GET /api/v1/schedules/calendar expands every live cron into one entry per firing across a window — the next 24 hours by default:

Terminal window
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:

Terminal window
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.

[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.

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.
Terminal window
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.

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.

  • Run event stream — GET /api/v1/runs/{run_id}/stream streams live run events, ending with the run’s terminal event. (valved run --watch does 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 as run.succeeded with data.status set 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 no event_filters a Slack subscription gets run.failed only — including a run whose worker died. Set [api] public_url in valved.toml to 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"}'
Terminal window
BASE=http://127.0.0.1:8765/api/v1
AUTH="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"}'