Pull-mode job protocol¶
Status: Alpha — version application/vnd.vworkspace.agent.v1+json.
Last Updated: 2026-05-30
This page is the wire-level HTTP contract between the operator (in Pull mode) and Odoo. It specifies the endpoints, the request and response shapes, the authentication model, the error codes, and the versioning convention. The conceptual treatment is in pull-mode.md; the sequence diagram is in ../diagrams/pull-mode-sequence.txt.
All endpoints live under /api/agent/ on the Odoo host the operator is registered against. All requests and responses are JSON unless otherwise noted. All bodies are encoded UTF-8.
Authentication and versioning¶
Every request carries two headers:
Authorization: Bearer <token>— the long-lived bootstrap credential the operator received during registration. The token is bound to one cluster identity; server-side authorization rejects any request for or about a different cluster.Accept: application/vnd.vworkspace.agent.v1+json— the media type the operator understands. Future major versions will use a different media type (v2+json, etc.). Odoo may serve multiple media types at once; the operator declares which one it speaks.
Content-Type: application/vnd.vworkspace.agent.v1+json is set on every request with a body. Responses use the same content type.
Optional client mTLS may be configured at the TLS layer; it does not change the application-layer protocol.
Endpoints¶
GET /api/agent/jobs¶
Long-poll for jobs targeted at the calling cluster.
Query parameters.
cluster(required) — the cluster identity. Must match the cluster identity bound to the bearer token; server returns403if it does not.wait(optional, default30, max300) — the number of seconds to hold the connection open if no jobs are immediately available. The server returns200 OKwith an emptyjobsarray if the wait elapses.
Success response: 200 OK.
Body:
{
"jobs": [
{
"id": "j-01HXYZA1B2C3",
"kind": "apply",
"payload": {
"apiVersion": "apps.vworkspace.io/v1alpha1",
"kind": "ApplicationInstance",
"metadata": {
"name": "nextcloud-myteam",
"namespace": "org-myteam",
"labels": {
"app.vworkspace.io/managed-by": "control-plane",
"app.vworkspace.io/cluster-id": "cluster-prod-1"
}
},
"spec": { "...": "..." }
},
"idempotencyKey": "applicationinstance/org-myteam/nextcloud-myteam@7",
"createdAt": "2026-05-28T10:00:00Z",
"expiresAt": "2026-05-28T10:15:00Z",
"signature": "base64-detached-signature-over-payload"
}
]
}
Field semantics.
id— a stable, opaque job ID. The same ID is used in theack,status, andresultendpoints.kind— one ofapply,delete, orintent.applycarries a rendered Kubernetes object;deletecarries a reference (apiVersion,kind,name,namespace) for deletion;intentcarries a higher-level structured record (see pull-mode.md).payload— forapply, a full manifest. Fordelete, an object reference. Forintent, a structured intent record.idempotencyKey— a stable key that the operator uses to de-duplicate replays. For object-shaped payloads the convention is<kind>/<namespace>/<name>@<generation>(or a stable hash for intent payloads). Re-delivering a job with the sameidempotencyKeyproduces a no-op.createdAt/expiresAt— RFC 3339 timestamps. The operator should skip jobs whoseexpiresAtis in the past and post aresultwithoutcome: noopanderror: "expired".signature(optional) — present when signed-payload mode is enabled. A detached signature over the canonical JSON encoding ofpayload, made with Odoo's signing key for this cluster. The operator verifies before applying.
POST /api/agent/jobs/{jobId}/ack¶
Acknowledge receipt of a job. The operator MUST call ack before applying. ack tells Odoo the operator intends to act on the job and authorizes Odoo to stop returning it from subsequent long-polls.
Request body. None (empty).
Success response: 204 No Content.
POST /api/agent/jobs/{jobId}/status¶
Post interim status for an in-flight job. Used for progress reporting (e.g., the HelmRelease is Reconciling). May be called any number of times between ack and result.
Request body.
{
"phase": "Reconciling",
"conditions": [
{
"type": "Reconciling",
"status": "True",
"reason": "HelmReleaseUpgrading",
"message": "Flux is upgrading nextcloud-myteam to chart version 6.6.0",
"lastTransitionTime": "2026-05-28T10:00:05Z"
}
],
"message": "Flux is upgrading nextcloud-myteam to chart version 6.6.0",
"timestamp": "2026-05-28T10:00:05Z"
}
Success response: 204 No Content.
POST /api/agent/jobs/{jobId}/result¶
Post the terminal result of a job. Must be called exactly once per job. After Odoo acknowledges result, the job is closed; further updates on the resource flow through POST /api/agent/events.
Request body.
{
"outcome": "succeeded",
"appliedRef": {
"apiVersion": "apps.vworkspace.io/v1alpha1",
"kind": "ApplicationInstance",
"namespace": "org-myteam",
"name": "nextcloud-myteam",
"uid": "8c9e5a3d-2e9c-4c8a-9c0a-1e3a4b5c6d7e",
"generation": 7
},
"timestamp": "2026-05-28T10:00:15Z"
}
outcome is one of:
succeeded— the job was applied and the resource reachedReady=True(forapply) or was deleted (fordelete).failed— the job could not be applied or the resulting resource entered a terminal failure condition.errorMUST be populated.noop— the job was already applied at thisgeneration(idempotent replay) or was already expired.erroris optional.conflict— the apply failed because another field manager holds a field the operator must own.errordescribes the conflicting field manager and field path.
A failed example:
{
"outcome": "failed",
"error": "HelmReleaseFailed: chart repository unreachable: dial tcp 10.0.0.1:443: i/o timeout",
"appliedRef": {
"apiVersion": "apps.vworkspace.io/v1alpha1",
"kind": "ApplicationInstance",
"namespace": "org-myteam",
"name": "nextcloud-myteam",
"uid": "8c9e5a3d-2e9c-4c8a-9c0a-1e3a4b5c6d7e",
"generation": 7
},
"timestamp": "2026-05-28T10:01:00Z"
}
Success response: 204 No Content.
POST /api/agent/events¶
Post a batched set of status, condition, and audit events that are not tied to a specific job. The canonical example is a HelmRelease that has just flipped to Degraded because the chart repository became temporarily unreachable, with no in-flight job to attribute that to.
Request body.
{
"events": [
{
"kind": "ConditionTransition",
"resourceRef": {
"apiVersion": "apps.vworkspace.io/v1alpha1",
"kind": "ApplicationInstance",
"namespace": "org-myteam",
"name": "nextcloud-myteam",
"uid": "8c9e5a3d-2e9c-4c8a-9c0a-1e3a4b5c6d7e"
},
"conditions": [
{
"type": "Ready",
"status": "True",
"reason": "HelmReleaseReady",
"message": "Release reconciliation succeeded.",
"lastTransitionTime": "2026-05-28T10:01:30Z"
}
],
"timestamp": "2026-05-28T10:01:30Z"
},
{
"kind": "ClusterHeartbeat",
"resourceRef": {
"apiVersion": "ops.vworkspace.io/v1alpha1",
"kind": "Cluster",
"name": "cluster-prod-1"
},
"conditions": [
{
"type": "ControllersHealthy",
"status": "True",
"reason": "AllControllersReady",
"message": "Flux, Velero, and cert-manager all report Ready.",
"lastTransitionTime": "2026-05-28T10:01:30Z"
}
],
"timestamp": "2026-05-28T10:01:30Z"
}
]
}
Field semantics.
events[]— an array of events. The operator batches up to a configurable size threshold (default 100) and time threshold (default one second), whichever is reached first. Empty batches are not sent.kind— the event kind. Standard kinds includeConditionTransition(a condition on an owned resource changed),ClusterHeartbeat(a periodic snapshot of the operator's ownClusterstatus),Audit(a notable action, e.g. anOperationwas admitted or rejected),BufferOverflow(the operator's outbound buffer dropped events during a disconnect).resourceRef— the object the event is about. UID is included where possible to disambiguate recreated objects.
Success response: 204 No Content.
Error responses¶
All error responses share the shape:
{
"error": "short_machine_readable_code",
"message": "human-readable explanation",
"requestId": "rq-01HXYZA1B2C3"
}
Standard status codes:
401 Unauthorized— missing, malformed, or expired bearer token. The operator should attempt one credential refresh; if the refresh also returns401, the operator surfacesAuthenticated=Falseon itsClusterstatus and stops polling until an admin intervenes.403 Forbidden— the token is valid, but the request references a cluster identity the token is not authorized for, an unknown organization, or a forbidden operation. Typical causes:clusterquery parameter does not match the token's cluster identity; the cluster's identity record has been disabled. The operator does not retry; it surfaces the condition and waits for re-registration.404 Not Found— the referencedjobIddoes not exist (already closed, never existed, or expired and pruned). The operator treats this as a no-op for the affected job. For other404cases (unknown endpoint), the operator surfaces a configuration error.409 Conflict— theresultfor this job has already been recorded. The operator treats this as success and proceeds. May also be returned byackif the job is already in a closed state.429 Too Many Requests— rate limit hit. The response carries aRetry-Afterheader (seconds) that the operator MUST honor. The operator's poll loop backs off exponentially within the limits the server suggests.5xx— server error. The operator retries with exponential backoff and jitter, never faster than once per second. Persistent5xxerrors surface asConnected=Falseon theClusterstatus.
Payload signing and encryption¶
The protocol supports two optional security layers above TLS, configured at the cluster identity level.
- Signed payloads. When enabled, every job in
GET /api/agent/jobsincludes asignaturefield — a detached signature over the canonical JSON encoding ofpayload, made with Odoo's signing key for this cluster. The operator verifies the signature before applying; verification failure is logged and reported as a job result withoutcome: failedand an expliciterror. The signing key is bound to the cluster identity record in Odoo; rotating it is a server-side operation. - Encrypted payloads. When enabled,
payloadis replaced withencryptedPayload, a JWE encrypted to the cluster's public key. The cluster decrypts at apply time. This is useful when payloads carry chart values that include secret material. The operator never logs decrypted material; only the apply outcome is logged.
Signing and encryption are independent: a cluster can enable signing, encryption, both, or neither. The default deployment enables neither (TLS plus the bearer token model is considered sufficient); regulated deployments often enable both.
Versioning¶
The protocol is versioned via the media type application/vnd.vworkspace.agent.v1+json. Backwards-compatible changes (new optional fields, new endpoints) extend v1. Backwards-incompatible changes mint a new media type (v2+json). Odoo can serve multiple media types simultaneously, allowing fleet-wide rollouts of the operator at different paces per cluster.
Within v1, fields the operator does not recognize MUST be ignored. Fields Odoo does not recognize on incoming requests are returned as 400 Bad Request only when they violate a documented constraint (e.g., an unknown outcome value); arbitrary additional fields are tolerated for forward compatibility.
The full conceptual treatment of why the protocol is shaped this way is in pull-mode.md.