REST endpoints + WebSocket events for Suitest OSS. All routes are mounted at
/api/v1/*unless stated otherwise. Input/output is validated with Pydantic v2 (seepackages/shared/schemas/).
ℹ️ Built today (M0–M3 foundation): auth, workspaces, TCM CRUD, runs, defects, requirements, integrations, webhooks, analytics,
/capabilities,/auth/me,WS /ws, deterministic generators (M2-1..M2-5), MCP-provider CRUD +/discover·/invoke·/routing(M2-6..M2-9),/workspaces/:id/llm-config(GET/PUT/test/DELETE/models — M3-2/M3-3). Not built (M3–M4 spec): agent/agent/*sessions+replay, eval, sdk, code export. Build truth =apps/api/src/suitest_api/routers/+ ROADMAP.md.Cross-links: DATA_MODEL.md · ARCHITECTURE.md · CAPABILITY_TIERS.md · MCP_PLUGINS.md · AUTONOMY.md · GENERATORS.md.
Suitest OSS uses FastAPI-Users (self-host friendly, multi-tenant) for sessions + OAuth, plus opaque API tokens for integrations.
| Method | Header | Used by |
|---|---|---|
| Session cookie | Cookie: suitest_session=... |
Web app (FastAPI-Users cookie backend) |
| OAuth (Google / GitHub) | redirect flow /auth/oauth/{provider}/{login,callback} |
Web app SSO |
| Bearer token | Authorization: Bearer suit_xxxxx |
CI integrations, SDKs |
API token format. Default = opaque random 32-byte token, prefix suit_, stored as sha256(token) server-side. Verification = constant-time hash compare. Tokens never retrievable after creation (display once).
Optional alt format (set SUITEST_API_TOKEN_FORMAT=paseto): signed PASETO v4 local tokens with workspace + scopes claims. Useful for stateless ephemeral CI tokens. Disabled by default to keep ZERO setup trivial.
Workspace context via X-Workspace-Id: ws_xxx header or path segment /api/v1/ws/:wsId/....
401 unauthenticated, 403 authenticated but lacking access to the workspace/resource.
- Versioning: path-based (
/api/v1). Breaking changes →/api/v2. - Pagination: cursor-based via
?cursor=<id>&limit=20. Default limit 20, max 100. - Filtering: query string, repeatable params (
?status=open&status=in_progress). - Sorting:
?sort=createdAt:desc. - Errors:
{ "error": { "code": "RESOURCE_NOT_FOUND", "message": "Test case TC-9999 not found", "details": { "resourceType": "test_case", "id": "TC-9999" } } } - Timestamps: ISO 8601 UTC (
2026-05-22T14:32:01.024Z). - IDs in responses: public ID (
TC-1045) in thepublicIdfield; the opaqueid(cuid) remains available for internal lookups. - Optimistic concurrency: mutation endpoints on test cases accept an optional
If-Unmodified-Sincerequest header (HTTP-date). Currently honoured byPATCH /test-cases/:idandPATCH /test-cases/:id/steps. If the header is older than the row'supdated_at, the server returns 409CONCURRENT_MODIFICATIONwithdetails.serverUpdatedAtso the client can refetch and retry. Header absent → last-write-wins (existing behaviour).
Endpoints that touch AI/MCP/autonomy raise dedicated error codes so the FE can render a clear banner/gate.
| Code | HTTP | Trigger | Body shape |
|---|---|---|---|
LLM_DISABLED |
503 | Workspace tier=ZERO (no LLM configured) but the endpoint requires an LLM |
{"error":{"code":"LLM_DISABLED","message":"This feature requires an LLM provider. Configure one in Settings → LLM.","docsUrl":"/docs/capability-tiers"}} |
STEPS_REQUIRE_CODE_IN_ZERO_LLM |
400 | When creating/running a case in ZERO, a step only has action without code |
{"error":{"code":"STEPS_REQUIRE_CODE_IN_ZERO_LLM","message":"Step #3 has no executable code. ZERO tier cannot translate action → MCP call at runtime.","details":{"stepIndex":3,"caseId":"ckxxx"}}} |
AUTONOMY_LEVEL_INSUFFICIENT |
403 | Action requires a higher autonomy level (e.g. auto-close defect while level=assist) |
{"error":{"code":"AUTONOMY_LEVEL_INSUFFICIENT","message":"Auto-close requires autonomy=semi_auto. Current: assist.","details":{"required":"semi_auto","current":"assist"},"docsUrl":"/docs/autonomy"}} |
MCP_PROVIDER_NOT_REGISTERED |
404 | Step/run references an MCP provider not registered in the workspace | {"error":{"code":"MCP_PROVIDER_NOT_REGISTERED","message":"MCP provider 'postgres-mcp' not registered.","details":{"name":"postgres-mcp"},"docsUrl":"/docs/mcp-plugins"}} |
MCP_PROVIDER_UNHEALTHY |
503 | Provider is registered but health_status != healthy when invoked |
{"error":{"code":"MCP_PROVIDER_UNHEALTHY","message":"MCP provider 'browser-use-mcp' is unhealthy (last check: 2026-05-26T10:12:01Z).","details":{"providerId":"mcp_xxx","lastHealth":"..."}}} |
TARGET_KIND_UNSUPPORTED_IN_TIER |
400 | Generator/run attempts a target_kind that requires an LLM in ZERO (e.g. FE_WEB semantic crawl) |
{"error":{"code":"TARGET_KIND_UNSUPPORTED_IN_TIER","message":"Semantic FE_WEB crawl requires CLOUD or LOCAL tier.","details":{"targetKind":"FE_WEB","tier":"ZERO","alternatives":["/api/v1/generators/crawler"]}}} |
INVALID_SOURCE_FOR_TIER |
400 | /agent/generate/cases with a source whose tier-appropriate counterpart is the deterministic generator (see §3.10) |
{"error":{"code":"INVALID_SOURCE_FOR_TIER","message":"OpenAPI generation is deterministic and available in ZERO tier via /generators/openapi.","details":{"source":"OPENAPI","tier":"ZERO","useEndpoint":"/api/v1/generators/openapi"}}} |
CONCURRENT_MODIFICATION |
409 | If-Unmodified-Since predates server updated_at on a mutating endpoint |
{"error":{"code":"CONCURRENT_MODIFICATION","message":"Test case was modified by another client.","details":{"resourceType":"test_case","id":"TC-1045","serverUpdatedAt":"2026-05-26T07:11:00Z"}}} |
CROSS_WORKSPACE_LINK |
400 | Attempt to link a requirement and case that live in different workspaces | {"error":{"code":"CROSS_WORKSPACE_LINK","message":"Requirement REQ-401 and case TC-1045 belong to different workspaces.","details":{"requirementWorkspaceId":"ws_a","caseWorkspaceId":"ws_b"}}} |
CROSS_WORKSPACE_IDS |
400 | Bulk endpoint received one or more ids outside the caller's workspace | {"error":{"code":"CROSS_WORKSPACE_IDS","message":"3 id(s) belong to a different workspace.","details":{"offendingIds":["TC-2001","TC-2002","TC-2007"]}}} |
BULK_LIMIT_EXCEEDED |
400 | Bulk endpoint called with more than the documented cap (currently 100 ids) | {"error":{"code":"BULK_LIMIT_EXCEEDED","message":"bulk-update accepts at most 100 ids per request.","details":{"received":137,"limit":100}}} |
FE rule of thumb: any 4xx/5xx with a code in the table above → render the Capability banner (not toast). For CONCURRENT_MODIFICATION and CROSS_WORKSPACE_*, render inline form error instead.
Surfaces tier + autonomy + MCP + embeddings state. No auth required — frontend boot fetches this before login screen so the login UI can show tier badge.
| Method | Path | Purpose |
|---|---|---|
| GET | /capabilities |
Public; full snapshot |
| GET | /capabilities/health |
Public; lightweight: {tier, status, uptime} for k8s liveness |
Authoritative schema: see CAPABILITY_TIERS.md § 10. The shape below mirrors that section verbatim; any drift here is a bug in this doc, not in the contract.
Sample GET /capabilities response (CLOUD tier):
{
"tier": "CLOUD",
"llm": {
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"base_url": null,
"is_test_provider": false
},
"embeddings": {
"enabled": true,
"backend": "openai",
"model": "text-embedding-3-small",
"dim": 1536
},
"features": {
"manual_tcm": true,
"deterministic_runner": true,
"deterministic_generator_openapi": true,
"deterministic_generator_recorder": true,
"deterministic_generator_crawler": true,
"ai_generation": true,
"ai_execution_agentic": true,
"ai_diagnose": true,
"ai_conversation": true,
"semantic_search": true,
"fts_search": true,
"auto_defect_filing_ai": true,
"auto_defect_filing_rule": true
},
"autonomy": {
"available": ["manual", "assist", "semi_auto", "auto"],
"default": "assist"
},
"mcpProviders": [
{ "id": "mcp_aaa", "name": "playwright-mcp", "kind": "playwright", "health": "healthy", "isDefault": true },
{ "id": "mcp_bbb", "name": "api-mcp", "kind": "api", "health": "healthy", "isDefault": true },
{ "id": "mcp_ccc", "name": "postgres-mcp", "kind": "postgres", "health": "unknown", "isDefault": false }
],
"version": "1.0.0"
}Sample GET /capabilities/health response:
{ "tier": "ZERO", "status": "ok", "uptimeSec": 123456 }| Method | Path | Purpose |
|---|---|---|
| GET | /auth/me |
Current user + memberships |
| GET | /workspaces |
List the user's workspaces |
| POST | /workspaces |
Create workspace |
| GET | /workspaces/:id |
Workspace detail |
| PATCH | /workspaces/:id |
Update workspace settings |
| GET | /workspaces/:id/members |
List members |
| POST | /workspaces/:id/members |
Invite member (email + role) |
| DELETE | /workspaces/:id/members/:userId |
Remove member |
| DELETE | /workspaces/:id |
OWNER only — destroy workspace; returns 202 Accepted + async cleanup job id |
| GET | /audit-logs |
Workspace-scoped audit trail, cursor-paginated (?cursor=&action=&resource_type=&user_id=&from=&to=&limit=50) |
M1e local auth + invite-only onboarding
| Method | Path | Purpose |
|---|---|---|
| POST | /auth/cookie/login |
Password login; FastAPI-Users form body username + password; sets suitest_session cookie |
| POST | /auth/forgot-password |
Start reset-password flow; stores encrypted reset link for super-admin review until SMTP exists |
| POST | /auth/reset-password |
Complete FastAPI-Users reset-password flow |
| PATCH | /api/v1/users/me/password |
Current user changes password; requires current_password + new_password |
| POST | /api/v1/workspaces/:id/invitations |
ADMIN/OWNER creates invite link; returns raw link once |
| GET | /api/v1/workspaces/:id/invitations |
ADMIN/OWNER lists invitations |
| POST | /api/v1/invitations/:id/revoke |
ADMIN/OWNER revokes invitation |
| POST | /api/v1/invitations/:id/resend |
ADMIN/OWNER rotates token and returns new raw link once |
| GET | /api/v1/invitations/validate?token= |
Public invite validation for /accept-invite |
| POST | /api/v1/auth/accept-invite |
Public accept invite; creates/activates user, membership, and session cookie |
| POST | /api/v1/admin/users/:userId/reset-password |
is_superuser only; returns one-time temporary password |
| GET | /api/v1/admin/password-reset-requests |
is_superuser only; lists encrypted reset links after decryption |
POST /auth/register is intentionally not mounted after M1e. Suitest OSS is invite-only after first-install bootstrap.
DELETE /workspaces/:id body (required):
{ "confirm_slug": "acme-prod" }confirm_slug must equal the workspace's slug. Mismatch → 400 with details.expectedSlug redacted. On success:
{ "jobId": "job_xxx", "status": "QUEUED" }Cleanup is asynchronous: tears down MCP provider sessions, deletes artifacts in R2, drops rows in single transaction per resource family. Subscribe to workspace:<wsId> for the terminal workspace.deleted event (or poll GET /jobs/:jobId — out of scope for M1d).
GET /audit-logs notes:
actionaccepts glob (integration.*,case.deleted,*). Defaults to all.from/toare ISO 8601 UTC, inclusive.limitdefault 50, max 200.- Cursor is opaque (encodes
(created_at, id)pair). When the response omitsnext_cursor, the caller has reached the head of the log. - Returns
403if caller is not at leastADMINin the workspace.
Response:
{
"items": [
{
"id": "audit_xxx",
"action": "case.updated",
"resourceType": "test_case",
"resourceId": "TC-1045",
"userId": "u_maya",
"createdAt": "2026-05-26T07:11:00Z",
"details": { "diff": { "priority": ["P1", "P0"] } }
}
],
"next_cursor": "eyJjcmVhdGVkQXQiOiI..."
}| Method | Path | Purpose |
|---|---|---|
| GET | /projects |
List projects (workspace-scoped) |
| POST | /projects |
Create project |
| GET | /projects/:id |
Detail |
| PATCH | /projects/:id |
Update |
| DELETE | /projects/:id |
Soft delete |
| Method | Path | Purpose |
|---|---|---|
| GET | /test-cases |
List, supports filters: ?suiteId&status&source&priority&tag&q |
| POST | /test-cases |
Create manual case |
| GET | /test-cases/:id |
Detail with steps |
| PATCH | /test-cases/:id |
Update metadata (honours If-Unmodified-Since) |
| DELETE | /test-cases/:id |
Soft delete |
| POST | /test-cases/:id/restore |
Revive soft-deleted case; idempotent |
| POST | /test-cases/:id/duplicate |
Clone |
| POST | /test-cases/:id/run |
Trigger ad-hoc run |
| PATCH | /test-cases/:id/steps |
Replace all steps (atomic, honours If-Unmodified-Since) |
| PATCH | /test-cases/:id/steps/reorder |
Atomic reorder of existing step ids |
| POST | /test-cases/:id/steps |
Append step |
| POST | /test-cases/bulk-update |
Bulk action across ≤100 cases (single transaction) |
| POST | /steps/test-once |
Dispatch a single step through MCP runner; returns inline result + artifact urls (role: QA+) |
POST /test-cases/:id/restore — no body. Returns 204 No Content. Re-POST after a successful restore returns 204 (idempotent). 404 if the case never existed or was hard-deleted.
POST /test-cases/bulk-update body:
{
"ids": ["TC-1045", "TC-1046", "TC-1047"],
"action": "set_priority",
"payload": { "priority": "P0" }
}Valid action values: delete (soft), move_to_suite (payload.suiteId required), set_priority (payload.priority), add_tags (payload.tags: string[]), remove_tags (payload.tags: string[]).
- Hard cap 100 ids per request →
400 BULK_LIMIT_EXCEEDEDif exceeded. - All ids must belong to caller's workspace →
400 CROSS_WORKSPACE_IDSwith offending ids if not. - Runs in a single DB transaction; either all rows mutate or none.
- Emits one
audit_logrow per mutated case; ids returned in response so the UI can correlate.
Response:
{ "updated": 3, "audit_ids": ["audit_a", "audit_b", "audit_c"] }PATCH /test-cases/:id/steps/reorder body:
{ "step_ids_in_order": ["step_a", "step_c", "step_b"] }Atomic: every existing step id must appear exactly once; otherwise 400 with details.missing / details.duplicate / details.unknown. Returns the updated step list (same shape as GET /test-cases/:id's steps).
POST /steps/test-once body:
{
"step": {
"action": "Open /login",
"expected": "Login form visible",
"mcpProvider": "playwright-mcp",
"targetKind": "FE_WEB",
"code": "await page.goto('/login'); await expect(page.locator('form')).toBeVisible();"
},
"context": {
"env": "staging",
"variables": { "BASE_URL": "https://staging.example.com" }
}
}Synchronous: dispatches one step through the MCP runner using the workspace's default routing (override by setting step.mcpProvider). Returns:
{
"outcome": "PASS",
"durationMs": 812,
"logs": [
{ "level": "info", "message": "navigation complete", "time": "..." }
],
"artifacts": [
{ "kind": "screenshot", "url": "https://r2.suitest.io/signed/..." }
]
}Role gate: QA or higher. Errors propagate as standard MCP errors (MCP_PROVIDER_NOT_REGISTERED, MCP_PROVIDER_UNHEALTHY, STEPS_REQUIRE_CODE_IN_ZERO_LLM). Used by the UI_SPEC §3.2.2 "Test step now" inline button.
Sample POST /test-cases:
{
"suiteId": "ckxxxx",
"name": "Login with valid credentials",
"description": "...",
"priority": "P0",
"source": "MANUAL",
"steps": [
{
"order": 1,
"action": "Open /login",
"expected": "Login form visible",
"mcpProvider": "playwright-mcp",
"targetKind": "FE_WEB",
"code": "await page.goto('/login'); await expect(page.locator('form')).toBeVisible();"
},
{
"order": 2,
"action": "Type valid creds",
"expected": "Dashboard loads",
"mcpProvider": "playwright-mcp",
"targetKind": "FE_WEB"
}
],
"tags": ["smoke", "auth"]
}Step validator behaviour (per tier). Validator runs on POST /test-cases and PATCH /test-cases/:id/steps:
| Tier | Step has code |
Step has only action |
Step missing both |
|---|---|---|---|
| ZERO | accepted | 400 STEPS_REQUIRE_CODE_IN_ZERO_LLM (workspace must have strict_zero_validation=true, default on) |
400 invalid |
| LOCAL/CLOUD | accepted | accepted (action→code translated at run time) | 400 invalid |
In all tiers mcpProvider is required; missing field → 400 MCP_PROVIDER_NOT_REGISTERED if name unknown to workspace, else accepted. targetKind defaults to FE_WEB when omitted.
| Method | Path | Purpose |
|---|---|---|
| GET | /suites?projectId=... |
List |
| POST | /suites |
Create |
| PATCH | /suites/:id |
Rename / reorder (optional case_order: string[] for atomic case reorder) |
| DELETE | /suites/:id |
Soft delete (cascade requires confirm) |
| POST | /suites/:id/restore |
Revive soft-deleted suite; idempotent (returns 204) |
PATCH /suites/:id body fields (all optional, at least one required):
{
"name": "Smoke — Checkout",
"description": "...",
"case_order": ["TC-1045", "TC-1046", "TC-1047"]
}When case_order is supplied, every case id currently in the suite must appear exactly once; otherwise 400 with details.missing / details.unknown. Case ids outside this suite → 400 CROSS_WORKSPACE_IDS if cross-workspace, else 400 with details.notInSuite. The reorder runs in the same transaction as any metadata update.
POST /suites/:id/restore — no body. Returns 204 No Content. Idempotent: re-POST after restore returns 204. Does not auto-restore child cases — those must be restored individually via POST /test-cases/:id/restore (or future POST /suites/:id/restore?cascade=true, out of scope for M1d).
| Method | Path | Purpose |
|---|---|---|
| GET | /runs |
List, filters: ?status&projectId&branch&env |
| POST | /runs |
Create + queue run |
| GET | /runs/:id |
Detail with summary |
| GET | /runs/:id/steps |
Run steps with outcomes |
| GET | /runs/:id/logs?cursor=... |
Streaming-friendly cursor pagination |
| GET | /runs/:id/artifacts |
List artifacts |
| GET | /runs/:id/artifacts/:artifactId |
Signed URL to R2 |
| POST | /runs/:id/cancel |
Cancel |
| POST | /runs/:id/rerun |
Re-trigger same run config |
Sample POST /runs:
{
"projectId": "ckxxxx",
"name": "Checkout E2E · Chrome 124",
"selection": { "type": "suite", "ids": ["ckxxxx"] },
// or { "type": "cases", "ids": ["TC-1045", "TC-1046"] }
// or { "type": "tag", "tags": ["smoke"] }
"branch": "main",
"commitSha": "a7f3e21",
"env": "staging",
"trigger": "MANUAL",
"parallelism": 4,
"mcpRoutingOverride": {
"FE_WEB": "playwright-mcp-headful",
"BE_REST": "api-mcp"
}
}
mcpProvideris no longer top-level on a run — eachTestStepcarries its own. The optionalmcpRoutingOverridemapstargetKind → provider nameand lets a single run flip provider per classification (e.g. swap headless for headful). The runner persiststier_at_runtimeon theRunrow so historical reproductions stay correct.
| Method | Path | Purpose |
|---|---|---|
| GET | /defects |
List, filters: ?status&severity&assigneeId&component |
| POST | /defects |
Create manually (the agent also uses the same endpoint internally) |
| GET | /defects/:id |
Detail |
| PATCH | /defects/:id |
Update (status, assignee, severity) |
| POST | /defects/:id/sync-external |
Force re-sync to Jira/Linear |
| GET | /defects/:id/timeline |
Activity log |
| Method | Path | Purpose |
|---|---|---|
| GET | /requirements |
List with link counts |
| POST | /requirements |
Create |
| GET | /requirements/:id |
Detail with linked cases + defects |
| POST | /requirements/:id/links |
Add link { caseId } |
| DELETE | /requirements/:id/links/:caseId |
Remove link |
| GET | /traceability/matrix?projectId=... |
Full matrix for grid view |
Sample matrix response:
{
"requirements": [
{ "id": "REQ-401", "title": "Sign in with Google", "tests": ["TC-1045"], "defects": ["SUIT-1284"] }
],
"cases": [ { "id": "TC-1045", "name": "...", "source": "AI", "status": "FAIL" } ],
"defects": [ { "id": "SUIT-1284", "title": "...", "severity": "CRITICAL", "status": "OPEN" } ]
}| Method | Path | Purpose |
|---|---|---|
| GET | /analytics/kpis?projectId&period=7d |
Pass rate, duration, runs count |
| GET | /analytics/pass-rate?projectId&period=30d |
Time series |
| GET | /analytics/flaky?projectId&minRate=0.05 |
Flaky test list |
| GET | /analytics/heatmap?projectId&period=14d |
Run count grid (day × hour) |
| GET | /analytics/readiness?projectId |
Release readiness score + blockers |
| GET | /analytics/coverage?projectId |
Coverage by suite + requirement |
| Method | Path | Purpose |
|---|---|---|
| GET | /integrations |
List for workspace |
| POST | /integrations |
Connect new integration |
| GET | /integrations/:id |
Detail (secrets redacted) |
| PATCH | /integrations/:id |
Update config |
| POST | /integrations/:id/test |
Smoke-test connection (existing, persisted integration) |
| DELETE | /integrations/:id |
Disconnect |
| POST | /integrations/:id/sync |
Trigger manual sync |
| POST | /integrations/jira/test-connection |
Validate Jira credentials before persisting |
| POST | /integrations/github/test-connection |
Validate GitHub App credentials before persisting |
Pre-save credential validation. The */test-connection endpoints spawn the relevant MCP adapter once with the provided env, hit a cheap "whoami"-style tool, and discard the process. They never persist a row in integrations. Used by the Settings → Integrations connect modal to flip the form's status pill before the user clicks Save. M1d Jira ships PAT / cloud-token only (no OAuth flow); OAuth is deferred to v1.x.
POST /integrations/jira/test-connection body:
{
"jira_url": "https://acme.atlassian.net",
"jira_email": "ops@acme.com",
"jira_token": "ATATT3xFfGF0...",
"jira_auth_type": "cloud_token"
}jira_auth_type ∈ cloud_token | pat. Spawns jirac-mcp with env JIRA_URL / JIRA_EMAIL / JIRA_TOKEN / JIRA_AUTH_TYPE, then invokes jira_api_request tool against GET /rest/api/3/myself. Response:
{ "ok": true, "account_id": "5b10ac8d82e05b22cc7d4ef5", "display_name": "Maya Ops" }or
{ "ok": false, "error": { "code": "JIRA_AUTH", "message": "401 Unauthorized" } }POST /integrations/github/test-connection body:
{
"app_installation_id": "48291023",
"private_key_pem": "<PEM-encoded RSA private key — see docs/DEPLOYMENT.md for GitHub App setup>"
}Spawns github-mcp-server with the installation token derived from the App private key, calls get_authenticated_user (or list_repositories if user endpoint unavailable for App tokens). Response shape mirrors Jira: { ok, account_id?, display_name?, error? }. The private_key_pem field is never persisted by this endpoint and never logged.
Both endpoints enforce a 10s timeout end-to-end and require role ADMIN+.
MCP integration POST body:
{
"kind": "MCP_BROWSER_USE",
"name": "Browser-Use Staging",
"config": {
"endpoint": "browser-use://staging",
"concurrency": 4,
"headless": true
},
"secrets": {
"apiKey": "buk_xxxxx"
}
}The
kindenum is expanded for the OSS pivot. Valid values:GITHUB, GITLAB, JENKINS, JIRA, LINEAR, SLACK, OPENAPI, MCP_BROWSER_USE, MCP_PLAYWRIGHT, MCP_CUSTOM, MCP_API, MCP_POSTGRES, MCP_KUBERNETES, MCP_GRAPHQL, MCP_GRPC, MCP_APPIUM, MCP_MONGO, MCP_MYSQL. Full list lives in DATA_MODEL.md §6. ForMCP_*kinds, prefer the dedicated MCP registry under §3.16 MCP Providers — the/integrationsendpoint is kept for backwards compatibility and CI/CD providers.
| Method | Path | Purpose |
|---|---|---|
| POST | /agent/sessions |
Start a new agent session |
| GET | /agent/sessions/:id |
Session + messages |
| POST | /agent/sessions/:id/messages |
Send user message (returns SSE stream) |
| POST | /agent/generate/cases |
One-shot generation, see details below |
| POST | /agent/diagnose/defect/:defectId |
Re-run root-cause analysis |
| POST | /agent/suggest/edge-cases |
Edge-case suggestions for an existing case |
POST /agent/generate/cases request:
{
"source": "PRD", // PRD | OPENAPI | URL_SEMANTIC | MCP
"input": {
"type": "text",
"value": "PRD-2026-Q1 § 4.2 ..."
// OR { "type": "url", "value": "https://api.suitest.io/openapi.json" }
// OR { "type": "documentId", "value": "doc_xxx" }
},
"targetSuiteId": "ckxxxx",
"targetKind": "FE_WEB", // optional, classifier override
"options": {
"maxCases": 10,
"priorityHint": "P0",
"tagPrefix": "oauth"
}
}Response: Server-Sent Events stream
event: progress
data: { "stage": "reading", "message": "Reading PRD section..." }
event: case
data: { "id": "draft_1", "publicId": "TC-1050", "name": "Google OAuth — happy path", "steps": [...] }
event: case
data: { "id": "draft_2", ... }
event: complete
data: { "totalGenerated": 5, "sessionId": "sess_xxx", "tokensUsed": 4821 }
The frontend uses EventSource to render cases as they stream in.
Tier behaviour for /agent/generate/cases. All four source values require an LLM. In ZERO tier this endpoint always errors — but the error shape depends on whether a deterministic alternative exists:
source |
ZERO behaviour | LOCAL / CLOUD behaviour |
|---|---|---|
PRD |
503 LLM_DISABLED (no deterministic equivalent) |
Streams cases |
URL_SEMANTIC |
503 LLM_DISABLED (heuristic crawler ≠ semantic understanding) |
Streams cases |
MCP |
503 LLM_DISABLED (LLM-driven tool discovery) |
Streams cases |
OPENAPI |
400 INVALID_SOURCE_FOR_TIER with hint {"useEndpoint":"/api/v1/generators/openapi"} — the deterministic /generators/openapi produces identical cases without an LLM, so callers should switch |
Streams cases |
The decision to return 400 + hint for
OPENAPIin ZERO (instead of auto-routing) is intentional: keep semantics of/agent/*as "AI agent endpoints", and surface the deterministic counterpart explicitly so users learn the right tool. See §3.17 Generators.
Sample 400 body:
{
"error": {
"code": "INVALID_SOURCE_FOR_TIER",
"message": "OpenAPI generation is deterministic and available in ZERO tier via /generators/openapi.",
"details": { "source": "OPENAPI", "tier": "ZERO", "useEndpoint": "/api/v1/generators/openapi" }
}
}| Method | Path | Purpose |
|---|---|---|
| GET | /documents |
List indexed sources |
| POST | /documents |
Add (PRD URL, OpenAPI URL, frontend URL) |
| POST | /documents/:id/resync |
Re-index |
| DELETE | /documents/:id |
Remove + drop chunks |
| Method | Path | Provider |
|---|---|---|
| POST | /webhooks/github |
GitHub Actions, push, PR |
| POST | /webhooks/gitlab |
GitLab CI |
| POST | /webhooks/jenkins |
Jenkins post-build |
| POST | /webhooks/jira |
Jira issue updates (for status sync back) |
Each webhook verifies the HMAC signature with the secret set when the integration was connected.
Same /api/v1/* endpoints but via Bearer token. Rate-limited 1000 req/min per token.
Additional helpers:
| Method | Path | Purpose |
|---|---|---|
| POST | /sdk/runs/start |
Concise: { tag, branch, commit } → returns runId |
| POST | /sdk/runs/:id/report |
External runner posts step outcomes |
| POST | /sdk/runs/:id/complete |
Finalize |
Workspace-scoped LLM provider config. Secrets stored AES-GCM encrypted (DATA_MODEL.md §12); API never returns plaintext keys.
| Method | Path | Purpose |
|---|---|---|
| GET | /workspaces/:id/llm-config |
Current config (key redacted as sk-****) |
| PUT | /workspaces/:id/llm-config |
Set/rotate provider + key (write-only key) |
| POST | /workspaces/:id/llm-config/test |
LiteLLM health_check round-trip |
| DELETE | /workspaces/:id/llm-config |
Clear config; tier downgrades to ZERO at next capability refresh |
| GET | /workspaces/:id/llm-config/models |
List available models for the configured provider (used by Settings → LLM model dropdown) |
GET /workspaces/:id/llm-config/models response. Returns array of model metadata for the workspace's currently-configured provider. Backed by LiteLLM litellm.model_list for providers that expose it, otherwise a hard-coded provider catalog shipped in packages/agent/providers/model_catalog.py. Empty array if tier=ZERO (no provider configured).
{
"provider": "anthropic",
"models": [
{
"id": "claude-sonnet-4-5-20250929",
"name": "Claude Sonnet 4.5",
"contextWindow": 200000,
"maxOutput": 8192,
"pricing": { "input_per_1m_usd": 3.00, "output_per_1m_usd": 15.00 }
},
{
"id": "claude-haiku-4-5",
"name": "Claude Haiku 4.5",
"contextWindow": 200000,
"maxOutput": 8192,
"pricing": { "input_per_1m_usd": 0.80, "output_per_1m_usd": 4.00 }
}
]
}PUT /workspaces/:id/llm-config body:
{
"provider": "anthropic", // anthropic | openai | gemini | groq | openrouter | ollama | llamacpp | vllm | lmstudio | …
"model": "claude-sonnet-4-5-20250929",
"apiKey": "sk-ant-…", // write-only; never returned
"config": { // optional, provider-specific
"baseUrl": "https://api.anthropic.com",
"timeoutMs": 60000,
"conversation_model": "claude-haiku-4-5" // optional override for CONVERSATION mode; defaults to provider's smallest model (e.g. claude-haiku-4-5, gpt-4o-mini). Other modes use top-level `model`.
}
}
config_json.conversation_modellets workspaces use a cheap/fast small model for the AI chat panel while keeping the heavyweightmodelfor GENERATION / EXECUTION / DIAGNOSIS. When omitted, the agent resolver inpackages/agent/providers/litellm_router.pypicks the provider's smallest model from the catalog returned byGET /llm-config/models.
GET response (key redacted):
{
"id": "llmcfg_xxx",
"provider": "anthropic",
"model": "claude-sonnet-4-5-20250929",
"apiKeyHint": "sk-ant-…last4",
"config": { "baseUrl": "https://api.anthropic.com", "timeoutMs": 60000 },
"isActive": true,
"lastValidatedAt": "2026-05-26T07:11:00Z",
"createdAt": "...",
"updatedAt": "..."
}POST /test response:
{ "ok": true, "latencyMs": 412, "modelEcho": "claude-sonnet-4-5-20250929" }or
{ "ok": false, "error": { "code": "PROVIDER_AUTH", "message": "401 from anthropic.com" } }Side-effects:
PUT⇒ recomputeworkspace_capabilitiesrow (tier may flip ZERO → CLOUD/LOCAL) + emitcapability.changedWS event.DELETE⇒ tier returns to ZERO; running agent sessions complete on current LLM, new ones rejected.
| Method | Path | Purpose |
|---|---|---|
| GET | /workspaces/:id/autonomy |
Current level + per-feature overrides |
| PUT | /workspaces/:id/autonomy |
Set level (manual / assist / semi_auto / auto) + optional overrides |
| GET | /workspaces/:id/autonomy/audit |
Paginated history of autonomy changes |
GET response:
{
"level": "assist",
"perFeature": {
"ai_generation": "assist",
"ai_diagnosis": "assist",
"defect_filing": "manual"
},
"tier": "CLOUD",
"updatedAt": "2026-05-26T07:00:12Z",
"updatedBy": "u_maya"
}PUT body:
{
"level": "semi_auto",
"perFeature": { "defect_filing": "auto" }
}403 AUTONOMY_LEVEL_INSUFFICIENT is raised when downstream actions exceed the configured level. Per-feature overrides require role ADMIN or OWNER. Each change appends a row to the audit table; surfaced via /audit.
Per-workspace MCP server registry. Distinct from /integrations — these are the runtime adapters that execute test steps. See MCP_PLUGINS.md.
| Method | Path | Purpose |
|---|---|---|
| GET | /mcp/providers |
List registered MCP servers + health |
| POST | /mcp/providers |
Register a custom MCP server (validates + discovers tools unless validate=false) |
| POST | /mcp/providers/test-connection |
Dry-run connect + tools/list without persisting (register modal) |
| GET | /mcp/providers/:id |
Detail (secrets redacted) |
| PATCH | /mcp/providers/:id |
Update config / endpoint |
| DELETE | /mcp/providers/:id |
Deregister |
| POST | /mcp/providers/:id/health |
Force health probe (returns latest) |
| POST | /mcp/providers/:id/invoke |
Dev aid — invoke an arbitrary tool to validate config (gated to ADMIN+) |
| GET | /mcp/providers/:id/tools |
List tools the MCP server exposes (introspection) |
| GET | /mcp/routing |
Current targetKind → providerId default mapping |
| PUT | /mcp/routing |
Override default routing (workspace-wide) |
POST /mcp/providers body:
{
"name": "postgres-mcp-staging",
"kind": "postgres", // browser-use | playwright | api | postgres | kubernetes | graphql | grpc | appium | mongo | mysql | custom
"endpoint": "stdio:///opt/mcp/postgres-mcp",
"transport": "stdio", // stdio | sse | ws
"configJson": { "schema": "public", "maxConnections": 4 },
"secretsJson": { "connectionString": "postgres://…" },
"isDefaultForTarget": { "DATA": true }
}M2-6 built:
GET/POST /mcp/providers,GET/PATCH/DELETE /mcp/providers/:idshipped inrouters/mcp_providers.py.GET /mcp/providersreturns the bundled builtins (syntheticbuiltin:<name>ids,isBundled=true, read-only) merged on top of custom workspace rows. Write fields use camelCase aliases (configJson/secretsJson/isDefaultForTarget);secretsJsonis write-only and never echoed. M2-7 built:POST /mcp/providersconnects + handshakes + runstools/listand persists the discovered catalog +health_status=ok+ version pins unlessvalidate=false; a failed probe returns422 {code: MCP_REGISTRATION_FAILED}and writes no row.POST /mcp/providers/test-connectiondoes the same probe without persisting. M2-8 built:POST /mcp/providers/:id/discoverre-runstools/listand persists catalog + health +last_health_at;POST /mcp/providers/:id/invokeis the dev-aid tool browser path — role-gated toADMIN+, audit-logged withinvocation_source=tool_browser+arg_hash(raw args never stored), and rejected (409) for bundled providers./routinglands in M2-9.
GET /mcp/providers/:id/tools response:
{
"providerId": "mcp_xxx",
"tools": [
{ "name": "query", "description": "Execute read-only SQL", "inputSchema": { "$ref": "..." } },
{ "name": "exec", "description": "Execute mutation", "inputSchema": { "$ref": "..." } }
],
"discoveredAt": "2026-05-26T07:00:00Z"
}POST /mcp/providers/:id/invoke (dev only, requires ADMIN):
{ "tool": "query", "input": { "sql": "SELECT 1" } }PUT /mcp/routing body (M2-9 — keys are target_kind, values pin a provider by name):
{
"overrides": {
"BE_REST": { "primary": "api-http-mcp", "fallback": "vendor-x-http" },
"DATA": { "primary": "mysql-mcp", "fallback": null }
}
}M2-9 built:
GET /mcp/routingreturns the effective table (bundled defaults fromsuitest_mcp.routing.DEFAULT_ROUTINGoverlaid with workspace overrides, each row taggedisOverride).PUT /mcp/routing(ADMIN+) replaces the override map, validating each referenced provider name is known + enabled (else422 MCP_PROVIDER_NOT_REGISTERED) and each key is a validtarget_kind(else422 INVALID_TARGET_KIND). Stored underworkspace_capabilities.features_json.routing_overridesin the{primary, fallback}shape the runner consumes viaresolve_provider. (The legacyworkspaces.mcp_routing_overridescolumn is not the runtime source.)
Errors:
404 MCP_PROVIDER_NOT_REGISTEREDwhen targeting an unknown id.503 MCP_PROVIDER_UNHEALTHYwhen invoking against a provider whose last health probe failed.
Deterministic + LLM-driven test generators. Deterministic ones (/openapi, /recorder, /crawler, /classify) work in all tiers including ZERO.
| Method | Path | Purpose | LLM required? |
|---|---|---|---|
| POST | /generators/openapi |
Parse OpenAPI spec, emit per-operation cases with executable step.code. (M3-8) options.includeLlmEdgeCases adds AI boundary/fuzz/negative cases when an LLM is active (else skipped) |
Core: No / edge: optional |
| POST | /generators/recorder/sessions |
Start browser recorder session → returns sessionId + WS room |
No |
| POST | /generators/recorder/sessions/:id/finalize |
Stop recording, materialise into a test case | No |
| POST | /generators/crawler |
Heuristic BFS crawl, fill forms with Faker, emit smoke cases | No |
| POST | /generators/prd |
(M3-6) LLM extracts user stories from a PRD → DRAFT cases (agentic steps). SSE. 409 LLM_NOT_CONFIGURED when no active LLM |
Yes (CLOUD/LOCAL) |
| POST | /generators/mcp-discovery |
(M3-9) LLM explores a registered MCP provider's tool catalog → DRAFT contract cases. SSE. 409 when no active LLM; 404 unknown provider; EMPTY_CATALOG error frame when no tools |
Yes (CLOUD/LOCAL) |
| POST | /generators/url-semantic |
(M3-7) LLM decomposes an intent ("checkout flow") on a URL → FE_WEB journey cases (playwright-mcp). SSE. 409 when no active LLM | Yes (CLOUD/LOCAL) |
| POST | /agent/chat |
(M3-12/M3-13) Conversation mode — streams token SSE frames; tool requests emit a tool frame + agent.tool.call WS event. 409 when no active LLM |
Yes (CLOUD/LOCAL) |
| POST | /llm/complete |
One-shot completion proxy for the lifecycle/MCP client (plan enrichment, Playwright codegen). Auth: API key (sk_suitest_…) or session. Body {prompt, system?, maxTokens?, temperature?} → {content, model, tokensIn, tokensOut, costUsd}. 409 when no active LLM (ZERO); 502 on provider error |
Implicit (409 = ZERO) |
| GET | /workspaces/:id/cost |
(M3-14) Per-provider/per-kind LLM spend rollups + soft daily budget (overBudget/alert) |
No |
| GET/PUT | /workspaces/:id/autonomy |
(M3-15/M3-16) Autonomy level + per-feature overrides + computed effective. PUT ADMIN+, audited. ZERO→manual only |
No |
| POST | /generators/classify |
Utility: input → {targetKind, recommendedMcp, recommendedStrategy} |
No |
POST /generators/openapi body:
{
"specUrl": "https://api.suitest.io/openapi.json",
// OR "specContent": "<raw YAML/JSON>",
"targetSuiteId": "ckxxxx",
"options": {
"includeNegativeAuth": true,
"includeSchemaValidation": true,
"tagPrefix": "api-contract"
}
}Response:
{
"generatorRunId": "gen_xxx",
"targetSuiteId": "ckxxxx",
"casesCreated": 47,
"publicIds": ["TC-2001", "TC-2002", "..."],
"durationMs": 312
}POST /generators/recorder/sessions body:
{
"projectId": "ckxxxx",
"startUrl": "https://staging.example.com",
"mcpProvider": "playwright-mcp"
}Response:
{
"sessionId": "rec_xxx",
"wsRoom": "recorder:rec_xxx",
"browserUrl": "http://recorder.local:9222/devtools",
"expiresAt": "2026-05-26T08:00:00Z"
}POST /generators/recorder/sessions/:id/finalize body:
{
"targetSuiteId": "ckxxxx",
"name": "Checkout — recorded happy path",
"priority": "P1"
}Returns the generated TestCase (with source: "RECORDER").
POST /generators/crawler body:
{
"projectId": "ckxxxx",
"url": "https://staging.example.com",
"depth": 3,
"authConfig": { "kind": "form", "loginUrl": "/login", "credentials": { "u": "...", "p": "..." } },
"targetSuiteId": "ckxxxx"
}POST /generators/classify body — auto-route preview:
{ "input": { "type": "url", "value": "https://api.example.com/openapi.json" } }Response:
{
"targetKind": "BE_REST",
"recommendedMcp": { "id": "mcp_xxx", "name": "api-mcp" },
"recommendedStrategy": "openapi-generator",
"alternatives": [
{ "strategy": "url-semantic", "requiresTier": "CLOUD" }
]
}Export a test case to runnable code in a target framework. Always available (no LLM required for the export itself — it walks step.code).
| Method | Path | Purpose |
|---|---|---|
| GET | /test-cases/:id/export?target=playwright |
Return generated code (text/plain) + Content-Disposition: attachment |
Supported target values: playwright (default), cypress, selenium.
Response (200):
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment; filename="TC-1045.spec.ts"
import { test, expect } from '@playwright/test';
test('Login with valid credentials', async ({ page }) => {
await page.goto('/login');
...
});
A row is written to code_exports for audit.
| Method | Path | Purpose |
|---|---|---|
| POST | /eval/runs |
Kick off an eval suite (gated to ADMIN+, CLOUD/LOCAL only) |
| GET | /eval/runs/:id |
Results |
POST body:
{
"evalSuiteName": "generate-from-prd-v1",
"promptVersionId": "pv_xxx",
"model": "claude-sonnet-4-5-20250929",
"fixturesPath": "evals/fixtures/prd-001/"
}GET response:
{
"id": "eval_xxx",
"evalSuiteName": "generate-from-prd-v1",
"fixturesCount": 25,
"passed": 23,
"failed": 2,
"model": "claude-sonnet-4-5-20250929",
"promptVersionId": "pv_xxx",
"results": [
{ "fixture": "checkout-001", "passed": true, "score": 0.94 },
{ "fixture": "checkout-002", "passed": false, "reason": "missed edge case 'sold-out'" }
],
"runAt": "2026-05-26T08:11:00Z"
}Path: /ws, native FastAPI WebSocket (no Socket.io). Auth via ?token=<bearer> query string or Authorization header during the HTTP upgrade.
After connecting, clients join rooms via the explicit subscribe.* events below:
workspace:<wsId>— global notifs (defect filed, integration error, capability/MCP changes)run:<runId>— live updates for a specific runagent-session:<sessionId>— message streamingrecorder:<sessionId>— live preview of recorder-captured steps
Wire format: each frame is a JSON envelope {"type": "<event>", "data": {...}} — keeps it Socket.io-compatible on the client side without the Socket.io protocol.
| Event | Payload | Trigger |
|---|---|---|
run.queued |
{ runId, position } |
Run enters queue |
run.started |
{ runId, startedAt, tier, mcpSession } |
Worker pick up |
run.step.started |
{ runId, stepIndex, action, mcpProvider, targetKind } |
Step begin |
run.step.log |
{ runId, stepIndex, level, message, time } |
Streaming log line |
run.step.completed |
{ runId, stepIndex, outcome, durationMs, artifacts } |
Step done |
run.completed |
{ runId, status, summary } |
Run done |
defect.created |
{ defectId, severity, testCaseId, runId, diagnosisKind } |
Auto-file |
defect.updated |
{ defect: DefectRead } |
Status / severity / assignee edit. Room: workspace:<wsId> |
case.created |
{ case: TestCaseRead } |
TC successfully created. Room: workspace:<wsId> |
case.updated |
{ case: TestCaseRead } |
Metadata / tag patch on TC. Room: workspace:<wsId> |
case.steps.replaced |
{ case_id, step_count } |
Atomic step replace (PATCH /test-cases/:id/steps or /steps/reorder). Room: workspace:<wsId> |
integration.error |
{ integration_id, kind, error_code, message } |
Adapter or webhook receiver fails (replaces integration.status for hard errors). Room: workspace:<wsId> |
agent.message.delta |
{ sessionId, role, contentDelta } |
Token streaming |
agent.tool.start |
{ sessionId, toolName, input, mcpProvider? } |
Agent invokes tool |
agent.tool.end |
{ sessionId, toolName, output, durationMs } |
Tool completes |
agent.tool.routed |
{ sessionId, toolName, targetKind, chosenMcpProvider, alternatives } |
NEW — emitted before agent.tool.start when the agent picks an MCP for a tool call (transparency / debug) |
agent.session.completed |
{ sessionId, summary, costUsd } |
Session done |
integration.status |
{ integrationId, status, error? } |
Health change |
mcp.provider.health |
{ providerId, name, status, latencyMs?, error? } |
NEW — MCP provider health probe state change |
capability.changed |
{ tier, autonomy, provider?, model?, embeddings, mcpProviderCount } |
NEW — broadcast to workspace:<wsId> on tier/autonomy/LLM-config/MCP changes |
generator.recorder.step |
{ sessionId, stepIndex, action, selector, url, screenshotUrl? } |
NEW — live preview: recorder just captured a step |
| Event | Payload | Purpose |
|---|---|---|
subscribe.workspace |
{ wsId } |
Join workspace room (capability / MCP / defect notifs) |
subscribe.run |
{ runId } |
Join run room |
unsubscribe.run |
{ runId } |
Leave run room |
subscribe.recorder |
{ sessionId } |
Join recorder live-preview room |
agent.cancel |
{ sessionId } |
Stop agent mid-generation |
| Audience | Limit |
|---|---|
| Web user (session cookie) | 600 req/min |
| API token | 1000 req/min |
| Webhooks | unlimited but signature required |
| Agent endpoints | 60 req/min per workspace |
| Generation specifically (LLM) | 20 req/min per workspace |
429 responses include a Retry-After header in seconds.
Per-tier note.
- ZERO — there are no LLM endpoints to limit. Deterministic generator endpoints (
/generators/openapi,/generators/recorder/*,/generators/crawler,/generators/classify) share the regular API budget. - LOCAL — local provider (Ollama / llama.cpp / vLLM) RPM is bounded by the model server's own concurrency; Suitest does not impose an extra LLM-side limit beyond the workspace cap.
- CLOUD — LiteLLM enforces the upstream provider's RPM/TPM. Suitest tracks remaining budget per
(workspace_id, provider, model)and returns429withRetry-Afterderived from upstreamretry-afterheaders when possible. - All generation endpoints (
/agent/generate/cases,/agent/diagnose/*,/agent/suggest/*) share a single LiteLLM rate-limit budget per workspace — a burst of generation throttles diagnosis and vice-versa. Budgets configurable viaSUITEST_LLM_RPM_LIMITenv.
Targeted M4:
suitest-py— Python SDK, generated from OpenAPI viaopenapi-python-client. Primary SDK (matches backend language; used by CI tooling and the CLI internally).@suiflex/suitest-sdk— TypeScript SDK, generated viaopenapi-typescript-codegen. Kept for browser/Node integration users.suitest— CLI, single Python entrypoint installed viauv:uv tool install suitest, thensuitest run --suite smoke --branch main.
Python (primary):
from suitest import SuitestClient
suitest = SuitestClient(api_key=os.environ["SUITEST_TOKEN"])
run = suitest.runs.start(
project_id="prj_xxx",
selection={"type": "tag", "tags": ["smoke"]},
branch="main",
)
async for event in suitest.runs.watch(run.id):
print(event.type, event.payload)TypeScript (browser/Node):
import { SuitestClient } from '@suiflex/suitest-sdk';
const suitest = new SuitestClient({ apiKey: process.env.SUITEST_TOKEN });
const run = await suitest.runs.start({
projectId: 'prj_xxx',
selection: { type: 'tag', tags: ['smoke'] },
branch: 'main',
});
for await (const event of suitest.runs.watch(run.id)) {
console.log(event.type, event.payload);
}FastAPI generates OpenAPI 3.1 automatically at GET /openapi.json (with Swagger UI at /docs, ReDoc at /redoc). Pydantic v2 schemas drive both runtime request/response validation and the OpenAPI document — there is no separate schema language.
SDK codegen pipeline:
- Python SDK (
suitest-py) → generated viaopenapi-python-client - TS SDK (
@suiflex/suitest-sdk) → generated viaopenapi-typescript-codegen
Build artifact openapi.json is committed to packages/shared/openapi.json on every release so SDK codegen is reproducible from a tag.
- Write the Pydantic schema first in
packages/shared/schemas/<resource>.py(request + response models, withmodel_config = ConfigDict(from_attributes=True)) - Add the table to this doc (section 3) — including new error codes in §2.1 if any
- Implement the handler in
apps/api/src/routes/<resource>.pyusing FastAPIAPIRouter, register it inapps/api/src/main.py.Depends(require_tier(...))is mandatory if the endpoint is LLM-dependent - Write pytest in
apps/api/tests/test_<resource>.py(pytest-asyncio strict) — minimum: happy path + 1 error path + 1 tier-gating path where relevant - Update SDKs (regenerate from
openapi.json—make sdkat the repo root regenerates both) - PR with the doc diff + an Alembic migration if it touches the schema
API-key (or session) authenticated endpoints used by the suitest test lifecycle / MCP publisher. All are tenant-scoped and never enqueue ARQ.
| Endpoint | Purpose |
|---|---|
POST /api/v1/ingest/resolve-project |
Validate/repair a project binding. Body {projectId?, projectSlug?, projectName?} → {status: valid|repaired|missing, projectId, matchedBy, candidates[]}. Read-only — never creates. |
POST /api/v1/test-cases/bulk-import |
Upsert generated cases (idempotent by (suite, slug) with name fallback; DB guard uq_test_cases_suite_slug, migration 0046). Explicit projectId must exist (404 otherwise — no implicit recreate); empty id + projectSlug find-or-creates. markStale: true flags MCP cases missing from the payload as STALE; re-importing a STALE case reactivates it. Returns {suiteId, projectId, imported[], stale[]}. |
POST /api/v1/runs/ingest |
Record a completed run. Each call creates a NEW test_runs row; results carry optional failureKind (structured failure class, stored in run_steps.state_snapshot). Returns {runId, projectId, status, total, passed, failed}. |