setu:: docs
Reference

API reference

Every HTTP endpoint Setu exposes — the browser-facing REST API under /api/v1, the outbound edge-agent protocol under /agent/v1, and the machine-to-machine operational routes. Method, path, auth, request/response shape, and the status codes each handler actually returns.

Setu is a Go control plane (a single chi router) split into three trust boundaries. The console REST API under /api/v1 is what the Next.js console calls; it is authenticated by an httpOnly setu_session cookie. The agent protocol under /agent/v1 is a separate boundary for edge runtimes, authenticated by an agent key, not a session. A handful of unauthenticated operational routes (plus one token-guarded /internal route) sit at the root.

Conventions

Base paths/api/v1 (console), /agent/v1 (agents), root (/healthz, /readyz, /metrics, /internal/*)
Content typeapplication/json in and out (SSE endpoints emit text/event-stream; artifact downloads emit application/x-ndjson)
Session authhttpOnly setu_session cookie, minted by signup/login and cleared by logout
Agent authAuthorization: Bearer <agent-key>
Rolesowner > admin > editor > viewer. Reads: any member. Content mutations: editor+. Workspace/member/invite admin: admin+. Delete workspace: owner.
Error envelopeJSON { "error": "…" } with the matching HTTP status
Success bodiesa DTO or list; deletes and side-effect writes usually return 204
Paginglists are either a bare array, an offset pager { items, total } (?page=N), or a keyset pager { items, nextCursor } (runs)
Auth failures, decoding & resource hiding
A missing or expired session returns 401 {"error":"unauthenticated"}. Resources outside your active workspace — and agent jobs not owned by the calling agent — return 404 rather than 403, so the API never leaks the existence of things you can’t see. Mutations you lack the role for return 403; conflicting state (last owner, non-empty workspace, referenced connection, active run) returns 409. JSON bodies are decoded with DisallowUnknownFields, so an unexpected key is a 400.
Secrets never travel in plaintext
Connection config is stored verbatim: secret references like ${secret.NAME} are kept as-is and resolved late — in the cloud vault, or on the edge for agent-placed connections. The named secret store is gated by SETU_SECRET_KEY; without it, writing a secret returns 412. CORS is an allowlist (SETU_CORS_ORIGINS); only listed origins get credentialed responses.

Auth & session

signup, login, and logout are the only /api/v1 routes registered outside the session middleware — they mint or clear the setu_session cookie (HttpOnly, Path=/; SameSite=Lax by default, or None; Secure when SETU_COOKIE_CROSS_SITE=true). Everything below requires a live session.

POST/api/v1/auth/signupnone
requestjson
{ "email": "you@acme.com", "password": "correct horse", "name": "Ada" }
response 200 (+ Set-Cookie: setu_session)json
{
  "user":      { "id": "…", "email": "you@acme.com", "name": "Ada" },
  "workspace": { "id": "…", "name": "Ada's workspace", "role": "owner" }
}

Password must be ≥ 8 chars. The very first account ever becomes owner of the Default workspace; everyone else gets their own “<name>’s workspace”. A duplicate email is 409; a malformed one is 400.

POST/api/v1/auth/loginnone
requestjson
{ "email": "you@acme.com", "password": "correct horse" }
response 200json
{
  "user":      { "id": "…", "email": "you@acme.com", "name": "Ada" },
  "workspace": { "id": "…", "name": "Default", "role": "owner" }
}

Unknown email and wrong password both return the same 401 invalid email or password (no account enumeration). A user with no membership gets 403 account has no workspace. The first membership becomes the active workspace.

POST/api/v1/auth/logoutcookie session

Deletes the server-side session and clears the cookie. Idempotent.

response 200json
{ "ok": true }
GET/api/v1/auth/mecookie session
response 200json
{
  "user":        { "id": "…", "email": "you@acme.com", "name": "Ada", "avatarUrl": "…" },
  "workspace":   { "id": "…", "name": "Default", "role": "owner", "avatarUrl": "…" },
  "memberships": [ { "id": "…", "name": "Default", "role": "owner" } ],
  "preferences": {}
}
POST/api/v1/auth/switch-workspacecookie session
requestjson
{ "workspaceId": "…" }
response 200json
{ "id": "…", "name": "Acme", "role": "editor" }

Not a member of the target → 403 you are not a member of that workspace.

PUT/api/v1/auth/profilecookie session
request (name 1–80 chars)json
{ "name": "Ada Lovelace" }
response 200json
{ "id": "…", "email": "you@acme.com", "name": "Ada Lovelace", "avatarUrl": "…" }
PUT/api/v1/auth/passwordcookie session
request → 204json
{ "currentPassword": "…", "newPassword": "…" }

New password must be ≥ 8 chars and differ from the current one; a wrong currentPassword is a 400. Succeeds by keeping the current session and revoking every other session for the user.

PUT/api/v1/auth/preferencescookie session
request (arbitrary JSON object, ≤ 64 KB → 204)json
{ "theme": "dark", "sidebar": "expanded" }

Stored verbatim. A non-object body, or one over 64 KB, is a 400.

PUT/api/v1/auth/avatarcookie session
request → 204json
{ "avatar": "data:image/png;base64,…" }

Accepts an https:// URL or a PNG/JPEG/WebP data URL, up to ~1 MB.

DELETE/api/v1/auth/avatarcookie session

Workspaces & members

Workspace and member administration is gated by role, not by the generic editor gate: renames, avatars, and member changes require admin+; deleting a workspace requires owner. Under-privileged calls are 403; non-members get 404 workspace not found.

POST/api/v1/workspacescookie session
requestjson
{ "name": "Acme" }
response 201json
{ "id": "…", "name": "Acme", "slug": "acme", "role": "owner" }
PATCH/api/v1/workspaces/{id}cookie session · admin+
requestjson
{ "name": "Acme Inc" }
response 200json
{ "id": "…", "name": "Acme Inc", "slug": "acme-inc" }
DELETE/api/v1/workspaces/{id}cookie session · owner

Refuses with 409 while the workspace still holds connections, pipelines, or datasets — empty it first. On success, 204.

PUT/api/v1/workspaces/{id}/avatarcookie session · admin+
request → 204json
{ "avatar": "https://…/logo.png" }
DELETE/api/v1/workspaces/{id}/avatarcookie session · admin+
POST/api/v1/workspaces/{id}/leavecookie session · member

Any member may leave; the sole remaining owner is blocked with 409. Returns 204.

GET/api/v1/workspaces/{id}/memberscookie session · member
response 200json
[ { "userId": "…", "name": "Ada", "email": "…", "role": "owner", "joinedAt": "…", "avatarUrl": "…" } ]
PATCH/api/v1/workspaces/{id}/members/{userId}cookie session · admin+
requestjson
{ "role": "editor" }
response 200json
{ "userId": "…", "role": "editor" }

You can’t set a role at or above your own, or act on a member who outranks you (403); demoting the last owner is 409. Fires a member.role_changed notification.

DELETE/api/v1/workspaces/{id}/members/{userId}cookie session · admin+

Blocked (403) unless you outrank the target (or it’s yourself); removing the last owner is 409. Returns 204 and notifies the removed user.

Invite links & direct invitations

Two mechanisms: reusable invite links (a hashed token, optional expiry and use-cap) and per-user invitations to an existing Setu account. Both minting paths require admin+ and forbid inviting at or above your own role.

GET/api/v1/invites/{token}none

Public peek so the accept page can render before login.

response 200json
{ "valid": true, "workspaceName": "Acme", "role": "editor" }
POST/api/v1/invites/{token}/acceptcookie session

Joins the workspace and switches your active session to it. Unknown token → 404; expired, revoked, or exhausted → 410 Gone. Re-accepting as an existing member is idempotent and consumes no use.

response 200json
{ "workspaceId": "…", "workspaceName": "Acme", "role": "editor" }
GET/api/v1/workspaces/{id}/invitescookie session · admin+
response 200 (no token echoed)json
[ { "id": "…", "role": "editor", "expiresAt": "…", "createdAt": "…", "uses": 0, "maxUses": 5 } ]
POST/api/v1/workspaces/{id}/invitescookie session · admin+
request (maxUses null = unlimited; expiresInDays 1–90)json
{ "role": "editor", "expiresInDays": 7, "maxUses": 5 }
response 201 (raw token returned once)json
{
  "id": "…", "role": "editor", "expiresAt": "…", "createdAt": "…",
  "uses": 0, "maxUses": 5,
  "token": "…", "url": "https://app…/invite/…"
}
DELETE/api/v1/workspaces/{id}/invites/{inviteId}cookie session · admin+
POST/api/v1/workspaces/{id}/invitationscookie session · admin+
request (invite an existing account by email)json
{ "email": "bob@acme.com", "role": "viewer" }
response 201json
{ "id": "…", "role": "viewer", "status": "pending", "inviteeEmail": "bob@acme.com" }

No account for that email, already a member, or already invited → 409. The invitee gets an invite.received notification (transactional).

POST/api/v1/invitations/{id}/acceptcookie session · invitee
response 200json
{ "workspaceId": "…", "workspaceName": "Acme", "role": "viewer" }

Only the invitee may accept (else 404); a non-pending invitation is 409.

POST/api/v1/invitations/{id}/declinecookie session · invitee

Returns 204 and notifies the inviter. Same ownership/state guards as accept.

Connectors & connections

Connectors are the declarative catalog; the console renders connection forms straight from their specs. Connections are saved instances with a placement of cloud or agent. Agent-placed connections push reachability checks (test, discover, preview) out to the edge runtime and return a job id to poll.

GET/api/v1/connectorscookie session

Every registered connector spec — fields, and source/sink support flags.

POST/api/v1/connectors/{kind}/testcookie session

Tests a not-yet-saved config. Secret refs are resolved first; nothing is persisted. role defaults to source (sink for write-only connectors).

requestjson
{ "config": { "host": "…", "password": "${secret.PG_PW}" }, "role": "source" }
response 200json
{ "ok": true }

Unknown connector → 400; a failed live probe → 502.

GET/api/v1/connectionscookie session

Bare array by default; ?archived=true for the archive; ?page=N (+ ?q, ?limit) → { items, total }.

response 200json
[
  {
    "id": "…", "name": "Prod Postgres", "provider": "postgres",
    "config": { "host": "…" }, "placement": "cloud",
    "agentId": null, "createdAt": "2026-01-02T…Z"
  }
]
POST/api/v1/connectionscookie session · editor+
request (agentId required when placement = agent)json
{
  "name": "Prod Postgres",
  "provider": "postgres",
  "config": { "host": "db", "password": "${secret.PG_PW}" },
  "placement": "cloud"
}
response 201json
{ "id": "…", "name": "Prod Postgres", "provider": "postgres", "config": {  }, "placement": "cloud", "createdAt": "…" }

name and provider are required; an agent placement whose agentId isn’t an agent in this workspace is a 400.

PUT/api/v1/connections/{id}cookie session · editor+

Name, config, and placement are editable; the provider (connector kind) is immutable. Unknown id → 404.

DELETE/api/v1/connections/{id}cookie session · editor+

Archives by default (soft delete, curation preserved); ?purge=true hard-deletes and cascades its datasets. A connection still referenced by live pipelines (archive) or any pipelines (purge) → 409. Success 204.

POST/api/v1/connections/{id}/restorecookie session · editor+

Brings an archived connection and its datasets back. 204.

POST/api/v1/connections/{id}/testcookie session

Probes the stored config without moving data. Agent-placed → runs on the runtime synchronously.

response 200json
{ "ok": true }
POST/api/v1/connections/{id}/discovercookie session · editor+

Lists the source’s streams and reconciles them into the dataset catalog atomically.

response 200 (cloud)json
{ "summary": { "added": 3, "updated": 1, "removed": 0 }, "datasets": [  ] }
response 202 (agent — poll the job)json
{ "jobId": "…", "async": true }
POST/api/v1/connections/{id}/previewcookie session

Reads a bounded sample from one source stream. limit defaults to 50, capped at 200. Empty stream → first discovered stream.

requestjson
{ "stream": "public.orders", "limit": 50 }
response 200json
{
  "stream": "public.orders",
  "fields": [ { "name": "id", "type": "int64", "nullable": false } ],
  "rows":   [ [ 1, "…" ] ],
  "truncated": false
}

Datasets, folders & mapping

GET/api/v1/datasetscookie session

Filters: connectionId, folderId, tag, q. Offset paging via limit (default 100, max 500) + offset.

response 200json
{
  "items": [
    {
      "id": "…", "connectionId": "…", "provider": "postgres",
      "physicalName": "public.orders", "displayName": "Orders",
      "type": "table", "schema": {  }, "rowEstimate": 12000,
      "status": "active", "folderId": null, "tags": [ "sales" ], "defined": false
    }
  ],
  "total": 42
}
GET/api/v1/datasets/tagscookie session
response 200json
{ "tags": [ "sales", "pii" ] }
GET/api/v1/datasets/{id}cookie session

Full datasetDTO. Unknown id → 404 dataset not found.

GET/api/v1/datasets/{id}/schemacookie session

Raw stored schema JSON (or {}), written unwrapped.

GET/api/v1/datasets/{id}/usagecookie session
response 200json
{ "pipelines": [ { "id": "…", "name": "Orders → Warehouse" } ] }
POST/api/v1/datasets/{id}/previewcookie session

Bounded row sample for the dataset’s underlying stream (previewDTO shape). Agent-placed connection → runtime passthrough.

request (optional body)json
{ "limit": 50 }
GET/api/v1/datasets/{id}/profilecookie session

Samples up to 500 rows and computes per-column stats.

response 200json
{
  "sampledRows": 500, "truncated": true,
  "columns": [
    { "name": "amount", "type": "float64", "nullable": false,
      "nulls": 0, "nullPct": 0, "distinct": 480, "min": "1.5", "max": "990" }
  ]
}
PATCH/api/v1/datasets/{id}cookie session · editor+

Any subset. folderId is tri-state: omit = keep, null/"" = move to root, uuid = move into folder.

requestjson
{ "displayName": "Orders", "folderId": null, "tags": [ "sales" ] }
DELETE/api/v1/datasets/{id}cookie session · editor+
POST/api/v1/connections/{id}/datasets/describecookie session

Previews a defined dataset (e.g. a SQL query) against a source connection without saving it. A connector that doesn’t support defined datasets → 422.

requestjson
{ "definition": { "query": "select * from orders where region = 'EU'" } }
response 200 (stream is always 'preview')json
{ "stream": "preview", "fields": [  ], "rows": [  ], "truncated": true }
POST/api/v1/connections/{id}/datasetscookie session · editor+

Persists a defined dataset. name required; a duplicate name → 409; unsupported connector → 422.

requestjson
{ "name": "EU Orders", "definition": { "query": "select …" } }
response 201json
{ "id": "…", "physicalName": "EU Orders", "defined": true, "definition": {  },  }
GET/api/v1/folderscookie session
response 200json
[ { "id": "…", "name": "Sales", "parentId": null } ]
POST/api/v1/folderscookie session · editor+
requestjson
{ "name": "Sales", "parentId": null }
PATCH/api/v1/folders/{id}cookie session · editor+
request (parentId null = move to root)json
{ "parentId": "…" }
DELETE/api/v1/folders/{id}cookie session · editor+
POST/api/v1/mappings/suggestcookie session

Stateless, deterministic source→target column alignment with confidence + reason. No data is read or written.

requestjson
{
  "source": [ { "name": "cust_id", "type": "int64" } ],
  "target": [ { "name": "customer_id", "type": "int64" } ]
}
response 200json
{ "suggestions": [ { "source": "cust_id", "target": "customer_id", "confidence": 0.92, "reason": "…" } ] }

Operators

GET/api/v1/operatorscookie session

Every operator spec with its resolved JSON Schema — the builder palette is data-driven from this.

GET/api/v1/operators/{type}/schemacookie session

The JSON Schema for one operator’s config, written unwrapped. Unknown type → 404.

POST/api/v1/operators/expression/validatecookie session
requestjson
{ "expr": "amount * 1.1", "columns": [ { "name": "amount", "type": "float64" } ] }
response 200 (invalid → ok:false + error)json
{ "ok": true }

Pipelines & schedules

A pipeline’s canonical form is a linear spec Document (source → operators → destination). Create/update validate the Document through a shared gate (Compile + incremental-sync rules); a diagnostic surfaces as a 400 shaped "NodeID: message".

GET/api/v1/pipelinescookie session

?archived=true for the archive; ?page=N (+ ?q) for numbered paging; otherwise a bare array.

POST/api/v1/pipelinescookie session · editor+
request (from connections + mode, or a full spec Document)json
{
  "name": "Orders → Warehouse",
  "sourceConnectionId": "…",
  "targetConnectionId": "…",
  "mode": "append"
}
response 201json
{
  "id": "…", "name": "Orders → Warehouse",
  "sourceConnectionId": "…", "targetConnectionId": "…",
  "spec": {  }, "createdAt": "…"
}
GET/api/v1/pipelines/{id}cookie session
PUT/api/v1/pipelines/{id}cookie session · editor+

spec (the full Document) is required; name is optional (blank keeps the current one). Connections are immutable. Validation failure → 400.

requestjson
{ "name": "Orders → Warehouse", "spec": {  } }
DELETE/api/v1/pipelines/{id}cookie session · editor+

Archives; ?purge=true hard-deletes and cascades run history (events, quarantine, state). 204.

POST/api/v1/pipelines/{id}/restorecookie session · editor+
GET/api/v1/pipelines/{id}/runscookie session

All runs for the pipeline (array of runDTO).

POST/api/v1/pipelines/{id}/runcookie session · editor+

?dryRun=true validates and reads without writing. Returns 202 with the created run (re-read post-orchestration, so a bound-runtime-offline failure is already reflected).

response 202json
{ "id": "…", "pipelineId": "…", "status": "pending", "phase": "extract", "dryRun": false, "createdAt": "…" }
POST/api/v1/pipelines/{id}/previewcookie session

Runs the live builder Document over a source sample and returns a per-node trace (rejected rows capped at 20/node). Falls back to the saved spec when spec is omitted.

requestjson
{ "stream": "public.orders", "limit": 50, "spec": {  } }
response 200json
{
  "stream": "public.orders",
  "source": { "stream": "public.orders", "fields": [  ], "rows": [  ], "truncated": false },
  "nodes":  [ { "type": "filter", "name": "…", "fields": [  ], "rows": [  ],
               "rejected": [ { "reasons": [ "amount is null" ], "row": [  ] } ] } ]
}
GET/api/v1/pipelines/{id}/secretscookie session

The secret references the pipeline’s connections need, and whether each is currently satisfied.

response 200json
[ { "name": "PG_PW", "satisfied": true }, { "name": "S3_KEY", "satisfied": false } ]
GET/api/v1/pipelines/{id}/schedulecookie session

Returns the schedule, or JSON null when none is set.

response 200json
{ "pipelineId": "…", "cron": "0 * * * *", "enabled": true, "dryRun": false, "nextRunAt": "…", "lastRunAt": "…" }
PUT/api/v1/pipelines/{id}/schedulecookie session · editor+

Upserts a 5-field cron schedule; an invalid expression is a 400. When enabled, the response carries the computed nextRunAt.

requestjson
{ "cron": "0 * * * *", "enabled": true, "dryRun": false }
DELETE/api/v1/pipelines/{id}/schedulecookie session · editor+

Schedule overview

GET/api/v1/schedulescookie session

Every schedule across the workspace (array of scheduleDTO).

GET/api/v1/schedules/previewcookie session

Validates a cron string and returns the next 3 fire times. Query: ?cron=0 * * * *.

response 200 (invalid → valid:false, next:[])json
{ "valid": true, "next": [ "2026-01-02T10:00:00Z", "2026-01-02T11:00:00Z", "2026-01-02T12:00:00Z" ] }

Runs, events & artifacts

GET/api/v1/runscookie session

Filters (all optional): status, pipelineId, since (RFC3339), q (pipeline name). Default keyset paging (?limit&cursornextCursor), or numbered (?page&limittotal).

response 200 (keyset)json
{ "items": [ { "id": "…", "status": "running", "phase": "load", "rowsRead": 900,  } ], "nextCursor": "…" }
GET/api/v1/runs/{id}cookie session

executedOn is added only on the single-run read — it reports whether the extract ran in the cloud or on a named agent.

response 200json
{
  "id": "…", "pipelineId": "…", "status": "succeeded", "phase": "load",
  "rowsRead": 12000, "rowsWritten": 11980, "rowsQuarantined": 20,
  "rowsTotal": 12000, "dryRun": false,
  "createdAt": "…", "startedAt": "…", "finishedAt": "…",
  "executedOn": { "kind": "agent", "name": "office-runtime" }
}
POST/api/v1/runs/{id}/cancelcookie session · editor+

Cooperative cancel. Idempotent: an already-finished or unknown run returns cancelling:false rather than an error.

response 200json
{ "ok": true, "cancelling": true }
GET/api/v1/runs/{id}/eventscookie session

Server-Sent Events tail of the run log until a terminal state (or ~10 min). Each frame is a runEventDTO.

streamtext
data: {"id":41,"ts":"…","level":"info","stream":"","message":"Extracted 12000 rows","rowsRead":12000,"rowsWritten":0}
GET/api/v1/runs/{id}/quarantinecookie session

Up to 200 rejected rows with their reasons and the raw row JSON. Rows can then be approved, discarded, or re-checked.

response 200json
[ { "id": 1, "stream": "public.orders", "reasons": [ "amount is null" ], "row": {  } } ]
POST/api/v1/runs/{id}/quarantine/approvecookie session · editor+

Writes selected quarantined rows into the pipeline’s destination (upsert when the target has a primary key, else append), bypassing the rule that rejected them, and marks each approved. Select by explicit ids or all:true (every still-pending row for the run). edits maps a row id → a partial {column: newValue} patch applied before the write. An agent-bound destination returns 409 (edge approval ships with the runtime); a non-writable connector → 400.

requestjson
{ "ids": [1, 2], "all": false, "edits": { "1": { "amount": 0 } } }
response 200json
{ "approved": 2, "failed": 0, "errors": [] }
POST/api/v1/runs/{id}/quarantine/discardcookie session · editor+

Marks selected rows discarded without writing them. Same { ids | all } selection.

response 200json
{ "discarded": 2 }
POST/api/v1/runs/{id}/quarantine/revalidatecookie session · editor+

Re-checks selected rows (with any edits applied) against the pipeline’s validation rules without writing — reports which now pass. Nothing is mutated.

response 200json
{ "revalidated": [ { "id": 1, "passes": true, "reasons": [] } ] }
GET/api/v1/runs/{id}/artifactscookie session

Lists staged blob artifacts for the run. No blob backend → 503; no staged data → 404.

response 200json
{
  "streams": [
    {
      "stream": "public.orders", "rows": 12000, "bytes": 900000, "parts": 2,
      "cursorField": "updated_at",
      "partList": [ { "n": 1, "rows": 6000, "bytes": 450000, "minCursor": "…", "maxCursor": "…" } ]
    }
  ]
}
GET/api/v1/runs/{id}/artifacts/{stream}cookie session

Streams one staged stream as decompressed JSONL (application/x-ndjson, attachment). Optional ?part=N (1-based) for a single part; an out-of-range part is 400, a missing manifest 404.

DELETE/api/v1/runs/{id}/artifactscookie session · editor+

Deletes the run’s staged data. A still-active run → 409; no artifacts → 204 (idempotent).

DELETE/api/v1/artifactscookie session · editor+

Purges staged artifacts for every finished run in the workspace.

response 200json
{ "deleted": 7 }

Secrets

Named cloud secrets referenced from connection config as ${secret.NAME}. Values are encrypted at rest and never returned. Writes require the store to be configured (SETU_SECRET_KEY).

GET/api/v1/secretscookie session
response 200 (names only)json
[ { "name": "PG_PW", "updatedAt": "…" } ]
PUT/api/v1/secrets/{name}cookie session · editor+

Name must match ^[A-Za-z_][A-Za-z0-9_]*$. No secret store configured → 412. Returns 204.

requestjson
{ "value": "s3cr3t" }
DELETE/api/v1/secrets/{name}cookie session · editor+

Unknown name → 404.

Notifications, search & overview

GET/api/v1/notificationscookie session

The caller’s notifications (?limit, default 50, max 200).

response 200json
[ { "id": "…", "type": "schedule.skipped", "title": "…", "body": "…", "payload": {}, "readAt": null, "createdAt": "…" } ]
GET/api/v1/notifications/unread-countcookie session
response 200json
{ "count": 3 }
POST/api/v1/notifications/readcookie session

Marks all as read. 204.

POST/api/v1/notifications/{id}/readcookie session

Idempotent per-notification mark; unknown/other-user id still returns 204.

GET/api/v1/notifications/streamcookie session

SSE of the caller’s new notifications (: ping heartbeat every 20s).

GET/api/v1/searchcookie session

Cross-entity search over datasets, pipelines, connections, and runs (5 hits per group). Query: ?q.

response 200json
{ "datasets": [ { "id": "…", "label": "Orders", "sublabel": "table" } ], "pipelines": [  ], "connections": [  ], "runs": [  ] }
GET/api/v1/overviewcookie session

Dashboard KPIs, deltas, outcomes, throughput, runs-per-day, and top pipelines. Window: ?range=24h|7d|30d|all (default 7d) or ?from&to.

response 200 (abridged)json
{
  "range": "7d",
  "kpis": { "rowsMoved": 120000, "rowsRead": 121000, "quarantined": 40,
            "finished": 12, "successRate": 92, "quality": 99, "avgDurationSec": 8.3 },
  "outcomes": { "succeeded": 11, "failed": 1, "cancelled": 0 },
  "topPipelines": [ { "id": "…", "name": "Orders → Warehouse", "rows": 90000 } ]
}
GET/api/v1/eventscookie session

Workspace-wide SSE activity stream of run changes (: connected, then data: frames; : ping every 20s).

Agents (console-side)

These manage edge runtimes from the console. The runtime’s own outbound protocol lives under /agent/v1 below.

GET/api/v1/agentscookie session
response 200json
[ { "id": "…", "name": "office-runtime", "status": "active", "version": "0.1.0", "lastSeenAt": "…", "createdAt": "…" } ]
POST/api/v1/agentscookie session · editor+
requestjson
{ "name": "office-runtime" }
response 201 (status starts 'enrolling')json
{ "id": "…", "name": "office-runtime", "status": "enrolling", "version": "", "createdAt": "…" }
POST/api/v1/agents/{id}/enroll-tokencookie session · editor+

Mints a one-time, 15-minute enrollment token. Only its hash is stored; the plaintext is returned once.

response 201json
{
  "token": "…",
  "expiresAt": "…",
  "installCommand": "setu enroll <token>",
  "controlPlaneUrl": "https://setu.example.com"
}
POST/api/v1/agents/{id}/test-connectioncookie session · editor+

Enqueues a test job on the runtime; poll the returned job id. An offline runtime → 409 runtime is not online.

requestjson
{ "provider": "postgres", "config": { "host": "…", "password": "${secret.PG_PW}" }, "role": "source" }
response 202json
{ "jobId": "…" }
DELETE/api/v1/agents/{id}cookie session · editor+

Soft-revokes the agent — its key stops authenticating. 204.

GET/api/v1/agent-jobs/{id}cookie session

Poll an agent job’s status/result (e.g. the async connection test). Workspace-scoped; unknown → 404.

response 200json
{ "id": "…", "kind": "test", "status": "succeeded", "result": { "ok": true } }

Agent protocol · /agent/v1

A separate trust boundary: edge runtimes authenticate by agent key, never a session. enroll is the only public route (an agent has no key until it exchanges an enrollment token); every other route sits behind agent auth and is scoped to the calling agent (jobs it doesn’t own return 404). This is the outbound-only long-poll protocol — see Long-polling & leasing.

POST/agent/v1/enrollnone

Exchanges a one-time enrollment token for a permanent agent key (returned once; only its hash is stored). An invalid/expired/replayed token → 401, no token consumed.

requestjson
{ "token": "…", "version": "0.1.0" }
response 200json
{ "agentId": "…", "agentKey": "…", "baseUrl": "https://setu.example.com" }
GET/agent/v1/jobs/next?wait={sec}agent key

Long-poll for the next job (lease = 60s). 200 with a leased job, or 204 when the wait window (capped at 30s) closes.

response 200json
{ "id": "…", "kind": "extract", "runId": "…", "connectionId": "…", "payload": {  } }
GET/agent/v1/jobs/{id}/extract-specagent key

Self-contained extract instructions for an extract job. Config carries ${secret.X} refs intact — never resolved cloud-side. Wrong kind → 404.

response 200json
{
  "runId": "…", "blobPrefix": "ws/…/pl/…/run/…/",
  "provider": "postgres", "config": {  },
  "streams": [ "public.orders" ], "blob": {  }
}
GET/agent/v1/jobs/{id}/load-specagent key

Load instructions for a load job, including the write mode and the compiled spec.

response 200json
{
  "runId": "…", "blobPrefix": "ws/…/pl/…/run/…/",
  "provider": "postgres", "config": {  },
  "mode": "append", "spec": {  }, "blob": {  }
}
POST/agent/v1/jobs/{id}/heartbeatagent key

Renews the lease while working. Always 204 (a renewal on a no-longer-leased job is a harmless no-op).

POST/agent/v1/jobs/{id}/resultagent key

status must be succeeded or failed. Idempotent completion; returns 204.

request (success)json
{ "status": "succeeded", "result": { "blobPrefix": "ws/…/run/…/", "rows": 12000 } }
request (failure)json
{ "status": "failed", "error": "connect: connection refused" }

Internal & operational

Root-level routes, outside both API groups.

POST/internal/scheduler/tickmachine token

Forces one scheduling pass — an external heartbeat that wakes a sleepy free-tier host so due schedules still fire. Bearer SETU_TICK_TOKEN (constant-time compared); when that env is unset the route 404s so it’s never left open. Returns 204.

GET/healthznone
response 200 (liveness)json
{ "status": "ok" }
GET/readyznone

Readiness — pings the DB pool (2s timeout). 200 {"status":"ready"} or 503 {"status":"unavailable"}.

GET/metricsnone

Prometheus metrics.

The shape of the surface
Two authenticated planes plus a few ops routes. The console talks credentialed /api/v1 over a cookie, gated by role (reads open to members, mutations at editor+, admin actions at admin+); edge runtimes talk /agent/v1 over an agent key, outbound-only, each scoped to itself. Secrets never cross either plane in plaintext — connection config carries ${secret.NAME} references that resolve in the cloud vault or on the edge, wherever the connection is placed.