API contract (OpenAPI) and SDKs¶
docs/api/openapi.json is the machine-readable contract of the platform's REST API (OpenAPI 3.1).
It is committed output: nobody edits it by hand, it is regenerated from the Spring controllers
and reviewed as a diff. The two first-party SDKs in sdk/ are generated from it.
core controllers --OpenApiSpecGenerator--> docs/api/openapi.json --sdk/generate.mjs--> sdk/typescript, sdk/python
Regenerate¶
- Write the spec. This runs the generator test in "write" mode (JDK 21 or newer, one Maven build at a time;
-amis needed orapplinks against stale module jars):
cd core
mvn -o -pl app -am test -Dtest=OpenApiSpecGeneratorTest -Dopenapi.write=true -Dsurefire.failIfNoSpecifiedTests=false
Without -Dopenapi.write=true the same test class only runs its checks and writes nothing. The
test finds the repo root by walking up from the working directory (the first directory holding
both docs/ and core/pom.xml) and refuses to write anywhere but docs/api/openapi.json. The
info.version is the core/pom.xml version without -SNAPSHOT.
- Regenerate the SDK sources and the AsyncAPI document (plain Node, no dependencies):
node sdk/generate.mjs
node scripts/docs/asyncapi.mjs
node sdk/generate.mjs --check exits 1 when the generated SDK files are out of date with the
spec, which is handy as a CI drift guard.
- Review and commit
docs/api/openapi.jsonand the generated SDK files together.
How the spec is produced¶
core/app/src/main/java/com/agenticai/core/apidocs/OpenApiSpecGenerator.java is a pure
reflection tool: it scans the classpath for @RestController classes under com.agenticai.core
with Spring's ClassPathScanningCandidateComponentProvider, reads the mapping annotations with
AnnotatedElementUtils, and walks DTO records and POJOs by reflection. It needs no application
context and no database, and it adds no dependency (springdoc is deliberately not used: the
offline Maven build has to keep working and that starter auto-exposes endpoints).
| Spec element | Source |
|---|---|
| paths, verbs | class-level @RequestMapping merged with method-level mapping annotations; regex in {name:regex} is dropped |
operationId |
<ControllerSimpleName>_<method>, suffixed _2, _3 when it would repeat |
tags |
the controller name without the Controller suffix |
description |
derived notes (public route, grant-key auth, feature flag from @ConditionalOnProperty, SSE), led by the curated prose in OpenApiDescriptions for routes whose contract reflection cannot see (session ownership, service-level feature flags); keyed by VERB /path, and OpenApiSpecGeneratorTest#curatedDescriptionsMatchRealOperations fails when a key no longer matches a scanned operation |
| parameters | @PathVariable, @RequestParam, @RequestHeader, @CookieValue (required flag, type, default); every {var} in a path is always declared |
| request body | @RequestBody (JSON or the declared consumes), @RequestPart / MultipartFile (multipart/form-data) |
| schemas | records and POJOs by reflection into components/schemas; @JsonProperty / @JsonIgnore; Bean Validation (@NotNull, @NotBlank, @Size, @Min, @Max, @Pattern, ...); enums; cycles end in a $ref |
| success response | the return type, unwrapping ResponseEntity, Optional, futures, Mono. Status: @ResponseStatus when present; else the one constant 2xx status the handler body sets (ResponseEntity.created/accepted/noContent, status(HttpStatus.X), new ResponseEntity<>(body, HttpStatus.X), response.setStatus(201)), read from the controller bytecode by OpenApiBodyStatusScanner, following lambdas and same-class helpers; else 204 for an empty DELETE; else 200. A status taken from a variable, or two different 2xx statuses, keeps the default. A 204 response has no content |
| streaming | Flux<ServerSentEvent>, SseEmitter or produces = text/event-stream become text/event-stream and carry x-sse: true |
| errors | 400, 401, 403, 404, 429, 500 reference one shared Error schema matching ErrorResponseFactory's body: RFC 9457 Problem Details (type, title, status, detail, instance) plus the legacy members (timestamp, error, code) and requestId/traceId, offered as application/problem+json and application/json (the latter when app.errors.problem-details.enabled=false); 429 (with Retry-After) only on the routes PublicEndpointRateLimitFilter.throttles names |
| headers | components.headers: X-Request-Id on the shared error responses; X-Total-Count and X-Has-More on list operations that take both limit and offset; Retry-After on 429 |
| security | bearerAuth (JWT) by default, with tenantApiKey (Bearer vdak_..., minted at /api/tenant/api-keys; the key's stored PERM_* scopes are its authority set) as an alternative on every authenticated /api/** route; security: [] for the permitAll routes; /api/dev/** and /mcp/** use apiKeyAuth (a vmcp_ grant key); /v1/** accepts a JWT or a grant key |
The permitAll list lives in OpenApiSecurity.PUBLIC_RULES, hand-mirrored from SecurityConfig.
OpenApiSpecGeneratorTest#publicAllowlistMirrorsSecurityConfig parses SecurityConfig.java and fails
when the two disagree, so a new permitAll() there has to be copied over.
Output is deterministic (sorted paths, verbs, components and skipped list; LF line endings; no timestamps), so a regeneration with no controller change produces an empty diff.
Paging convention¶
List endpoints that can grow without bound take optional limit and offset query parameters and answer
with two additive response headers; the JSON body keeps its original shape (a plain array, or the
object it always was).
| Piece | Meaning |
|---|---|
limit |
rows to return, clamped to [1, max]; the maximum differs per endpoint |
offset |
rows to skip, newest first (a negative value is 0; above 1,000,000,000 returns an empty page with 200) |
X-Total-Count |
the number of rows matching the request's filters before paging |
X-Has-More |
true when rows remain beyond this page |
Without limit, the four original list endpoints return the complete list, as before pagination
existed; an opt-in server switch (LIST_DEFAULT_CAP, see
Configuration) caps them instead. The audit log,
platform users, notifications, quick-task history and AI reports have a bounded default instead. The
per-endpoint maximums and defaults, and the list endpoints that are still unpaged, are in
List endpoint paging, which is the source of truth: it is not repeated here.
Order is newest first with id as tiebreaker, so consecutive pages do not repeat or skip rows.
API conventions¶
Cross-cutting behaviour every client can rely on. Each one is in the generated spec (and therefore the SDKs), read from
the same static rule the runtime filter applies, and pinned by OpenApiConventionsTest.
| Convention | What the server does | In the spec |
|---|---|---|
| Errors | RFC 9457 Problem Details (application/problem+json) with a stable type, plus the legacy members |
Error schema, shared error responses |
| Correlation | X-Request-Id on every response (the caller's own when it sent a valid one) |
components.headers.X-Request-Id |
| Authentication | Keycloak JWT, or a tenant API key Bearer vdak_... on /api/** |
bearerAuth, tenantApiKey |
| Permissions | URL rules in SecurityConfig |
x-permissions (anyOf / allOf) and x-roles, read from the security rule snapshot, plus a sentence in the description |
| Idempotent retries | Idempotency-Key on authenticated JSON POST/PATCH under /api/**: a repeat replays the first response (Idempotent-Replayed: true) for 24 h; 409 while the first is running; 422 for the same key with a different body. Workflow and evaluation runs keep their own older semantics. IdempotencyKeyFilter |
components.parameters.IdempotencyKey, 409, 422 |
| Optimistic concurrency | ETag on GET /api/agents/{id} and GET /api/workflows/{id}; If-Match on their PUT/PATCH/DELETE and on single-segment settings PATCHes refuses a stale write with 412. ConditionalRequestFilter |
ETag header, components.parameters.IfMatch, 412 |
| Offset paging | limit / offset, X-Total-Count / X-Has-More |
headers on every list operation with both parameters |
| Keyset paging | cursor on the audit log and workflow run history: follow X-Next-Cursor; no count query, stable under inserts |
cursor parameter, X-Next-Cursor header |
| Rate limits | Public routes: per IP/device, 429 + Retry-After. Authenticated API: opt-in (app.rate-limit.api.enabled) per-caller budget with IETF RateLimit / RateLimit-Policy headers |
429 on the throttled public routes; RateLimit* header components |
| Deprecation | Deprecation, Sunset, Link headers; 180-day minimum notice |
deprecated: true, x-sunset, x-replacement. Policy: DEPRECATION.md |
| Webhooks | Signed (X-VegaDuta-Signature: t=..,v1=<HMAC-SHA256>) at-least-once POSTs of the WorkflowEvent envelope, retried with backoff |
top-level webhooks object |
| Event streams | SSE streams, webhooks and the voice relay WebSocket | asyncapi.json (AsyncAPI 3.0), generated by node scripts/docs/asyncapi.mjs |
Turn-lifecycle step events¶
A chat stream whose request sets stepEvents: true carries step events in-band, interleaved with the reply
text: each is an SSE data: chunk that is the marker @@AGENT_STEP_EVENT_4d81ba@@ immediately followed by
one line of JSON. Besides the tool and run events (tool_call, tool_result, error, phase,
run_started, run_finished, guardrail...), the turn says what it is doing beyond "working" with five
lifecycle events. Their fields are machine values (a code, a count, a model or tool name, an id), never
wording, so a client keys its chips and notices on them and not on the English notice the platform also
appends to the reply. They are additive and sent only to a client that asked; the schemas are the
agentStep_* messages of asyncapi.json.
type |
Fields | When |
|---|---|---|
finish |
finishReason (stop, length, safety, malformed_tool_call, empty, interrupted), optional detail (the provider's word for a refusal) |
Once per reply, when it ends, before run_finished. A client offers "Continue" on length and interrupted and shows its refusal notice on safety |
retry |
attempt (the try about to run, 1 is the first), maxAttempts, reason |
The same model is called again after a failure before its first token, or the reply is asked for again after an unusable tool call |
failover |
toModel (absent when not known by name), reason |
The turn moved to the agent's fallback provider or the next model of the platform-hosted pool |
continuation |
pass, maxPasses, reason (cut_off, unfinished_step, approval) |
Another model pass carries the reply on; approval is the turn the platform starts after a person decided a held tool call |
approval_required |
tool, approvalId |
A tool call was recorded as a pending approval; sent after that call's tool_result |
reason on retry and failover is one of rate_limited, timeout, provider_unavailable,
auth_failed, billing, model_not_found, provider_error or (retry only) malformed_tool_call; a
client shows an unknown code with its underscores read as spaces. A client that gets no finish event
(an older core) falls back to recognising the reply-text markers. The console's chips and notices read
these fields (web/app/components/agents/playground/liveStatus.ts, turnOutcome.ts), and
node scripts/docs/asyncapi.mjs --check fails when the documented types or fields no longer match
AgentStepEventPayloads.
Tenant ROI and pilot endpoints¶
/api/tenant/roi/** backs the per-agent cost and ROI view and the pilot report. The behaviour, the
permission each route needs and how the numbers are derived are in Cost and ROI
and the pilot kit; the routes in RoiController are:
| Route | Purpose |
|---|---|
GET /api/tenant/roi/agents, GET .../agents.csv |
per-agent table (JSON or CSV) |
GET /api/tenant/roi/costs |
priced cost grouped by agent, workflow, channel, owner or day |
GET /api/tenant/roi/budget |
spend against the monthly cap |
GET/POST /api/tenant/roi/prices, DELETE .../prices/{id} |
model price versions |
GET/PUT .../agents/{agentId}/assumptions |
tenant-entered value assumptions |
GET/PUT .../agents/{agentId}/baseline |
tenant-entered pre-pilot baseline |
POST /api/tenant/roi/outcomes |
mark a conversation outcome |
GET .../agents/{agentId}/pilot-report?from&to&includeAssumed&format=html\|pdf\|json |
one-page pilot report (default html; any other format is a 400) |
GET /api/tenant/roi/demo |
whether the workspace is a seeded Example tenant |
Size of the committed contract¶
Counted from docs/api/openapi.json (info.version 0.1.0) in this revision: 1,135 paths, 1,386
operations, 1,432 component schemas, and 5 handlers listed in info.x-skipped. These change with every
regeneration; recount rather than trust this paragraph. Standards conformance (MCP, A2A, AG-UI,
OpenTelemetry) is a separate, generated page: Standards conformance.
Known limits¶
- A handler that cannot be described is skipped, not fatal. Skips (no HTTP method on the mapping,
ant wildcards such as
/**, property placeholders in a path, classes that fail to load) are listed ininfo.x-skipped. - The exact 2xx status is not knowable by reflection when a handler builds its own
ResponseEntity. The spec says 200 (204 for an emptyDELETE, or the@ResponseStatus), and the SDKs accept any 2xx. - Handlers returning
ResponseEntity<?>have an untyped ({}) success body. - Required-ness is conservative: a field is
requiredwhen it is a primitive or carries@NotNull,@NotBlankor@NotEmpty. A primitive also carries its Java default, and the SDKs treat a required field with adefaultas optional on input. - Permission requirements finer than "authenticated" (for example
PERM_MANAGE_AGENTS) are not in the spec; they live inSecurityConfig. - Plan/limit failures (403 upgrade, 402 budget, 423 locked) share the generic
Errorbody; their extra fields are allowed byadditionalProperties. - 429 is documented only for the public routes
PublicEndpointRateLimitFilterthrottles. Handlers that answer 429 themselves (turn concurrency, demo and share limits) and the opt-in authenticated API budget are not listed per operation. x-permissionscomes fromcore/app/src/test/resources/security/authorization-rule-snapshot.txt, whichSecurityRuleSnapshotTestkeeps equal to the real filter chain. Permission checks made inside a handler (sharing levels, ownership) are not in it.
Why the platform stays REST + OpenAPI rather than adding GraphQL, and the API roadmap (breaking-change
gate, webhooks in the spec, Idempotency-Key, ETag/If-Match, rate-limit headers, deprecation
policy), are in ADR-0026.
Status of the committed files¶
docs/api/openapi.json and the generated SDK sources (sdk/typescript/src/generated/{types,client}.ts,
sdk/python/vegaduta_sdk/generated/{__init__,models,client}.py) are produced by the steps above and have to be
generated on a machine with JDK 21+ and Node. They are intentionally not hand-written.
Checks that keep the contract honest¶
| Check | Fails when | Runs in |
|---|---|---|
OpenApiSpecFreshnessTest (core/app) |
the committed docs/api/openapi.json differs from a regeneration; names the first differing line |
mvn verify (core CI job) |
node sdk/generate.mjs --check |
the generated SDK sources differ from docs/api/openapi.json |
make gates (static gates) |
node scripts/docs/asyncapi.mjs --check |
docs/api/asyncapi.json differs from a regeneration, or a documented workflow-run event name no longer exists in core/workflow |
make gates (static gates) |
ConsumerContractVerificationTest (core/app) |
an interaction in contracts/consumers/** names an operation, status or field the generated spec does not have |
mvn verify (core CI job) |
node scripts/api-contract.mjs --check and app/lib/api/consumerContract.test.ts (web) |
the console calls an endpoint missing from contracts/consumers/web, or the contract is stale |
web CI job |
node scripts/ci/openapi-breaking.mjs |
docs/api/openapi.json changed incompatibly against the merge base with the base branch (below) |
make gates (static gates) |
Breaking-change gate¶
scripts/ci/openapi-breaking.mjs (plain Node, no dependencies) reads the spec at the merge base
(git show <merge-base>:docs/api/openapi.json) and compares it with the working tree. It fails on:
| Rule | Example |
|---|---|
path-removed, operation-removed |
a route or a verb is gone (renaming a path variable, {id} to {agentId}, is not a break) |
operation-id-changed |
AgentController_reliability became AgentChatSessionController_reliability after a controller split; the SDK method name derives from it |
parameter-required, request-body-required, request-property-required |
an input is new and required, or was optional; a property with a default (a Java primitive) is exempt |
parameter-removed, request-property-removed |
a query/header parameter or a request property is gone, so the server ignores what clients send (a schema with additionalProperties: true is exempt) |
security-tightened, access-tightened |
an operation that was public now needs credentials, stops accepting a security scheme, or now needs a role or permission (x-roles, x-permissions), or one that sufficed no longer does |
constraint-tightened |
an input gained or tightened minLength/maxLength, minimum/maximum, minItems/maxItems, pattern or format (int32 to int64 and float to double widen, so they pass) |
composition-narrowed |
an input's oneOf/anyOf lost an alternative |
request-media-type-removed |
the request body no longer accepts a content type it accepted |
response-body-removed, response-property-removed |
a 2xx body or one of its properties is gone (a 200 that became 201 is compared as the same response: the gate does not flag the code itself) |
response-required-relaxed, constraint-loosened, composition-widened |
a response property that was always present may now be missing; a response lost or loosened a bound or pattern, or changed format; a response oneOf/anyOf gained an alternative |
enum-narrowed |
an enum lost a value, or an input gained an enum it did not have |
type-changed |
a schema type changed; an input may widen (string to string\|null) and an output may narrow |
A shared component is reported once, at schemas.<Name>.<property>, not once per operation that uses it.
The base is the PR base in CI (GITHUB_BASE_REF), the push's previous commit on a push, and main
locally; --base <ref> or OPENAPI_BREAKING_BASE_REF override it, and --old a.json --new b.json
compares two files. When the break is deliberate, add the printed key to
scripts/ci/openapi-breaking-allowlist.txt with a # reason (an entry without one fails), say so in the
CHANGELOG, and remove the entry once it is merged. The rules are tested against a fixture pair in
scripts/ci/testdata/openapi-breaking/ (node --test scripts/ci/openapi-breaking.test.mjs).
So a controller change is complete when the spec, the SDKs and (if the console uses the route) the web contract are
regenerated in the same change. The contract format and the extraction rules are in
ADR-0025; make contracts runs the contract checks locally.