Deploy-intent contract
A DeployIntent is a versioned, CRD-agnostic description of a whole deploy. A client sends one document; the operator translates it into Kubernetes objects, so a CRD schema change does not break the client.
This page is for adapter authors: a deploy adapter, a CI step, a plugin in another repository. It covers what a schema cannot express: what the server materializes, how to read a response, what a redeploy leaves alone, and the rules enforced only in server-side validation.
The schema of record is
api/openapi/openapi.yaml;
it carries the field lists. This page covers the behaviour behind them.
Endpoints
Section titled “Endpoints”GET /api/workspaces/{workspace}/deploy-profilePOST /api/workspaces/{workspace}/deploymentsBoth are served by the dashboard. GET /deploy-profile answers from the
workspace’s Provider and SkillSource CRDs and requires a viewer role.
POST /deployments forwards the intent document opaquely to the operator’s
deploy-intent API, decorates the response with console URLs, and requires an
editor role. The dashboard does not validate or interpret intent fields.
Version negotiation
Section titled “Version negotiation”The contract is versioned in the document itself:
{ "apiVersion": "deploy.omnia.altairalabs.ai/v1", "...": "..." }deploy.omnia.altairalabs.ai/v1 is the only version. An unrecognised
apiVersion returns 400 rather than a best-effort parse.
The deploy profile (GET /deploy-profile) lists what an installation
accepts: supportedDeployIntentVersions gives the contract versions the
operator serves. The same document lists the workspace’s Providers (name,
role, type, model) and SkillSources for discovery, and never contains a
secret.
What one intent materializes
Section titled “What one intent materializes”A single POST creates or updates this object set, in this order:
| Object | Applied as | Notes |
|---|---|---|
| ConfigMap (pack content) | create-only | Holds the raw pack.json; treated as opaque bytes. Owned by the PromptPack for garbage collection. |
| PromptPack | create-only | An existing pack of the same name is reported unchanged, never rewritten. |
| ToolRegistry | create-only | Only when tools.handlers is given. See Credentials. |
| Secret (tool credentials) | upsert | Only when credentials is given. Owned by the ToolRegistry. Upsert because credentials rotate. |
| ToolPolicy | upsert | Only when policy.toolBlocklist is given. Enterprise only. |
| AgentRuntime (one per agent) | upsert, field-merged | See What a redeploy preserves. |
A redeploy of the same pack version is a no-op for ConfigMap and
PromptPack: create-only means it reports unchanged, never rewrites. A
pack is immutable by version — publish new content as a new version.
pack.version spelling
Section titled “pack.version spelling”pack.version accepts an optional leading v (v1.2.3 or 1.2.3). The
server strips it before deriving the PromptPack object or the pin copied to
every agent. The PromptPack CRD’s own spec.version has one spelling, with
no leading v.
Deploying a project to a dev agent
Section titled “Deploying a project to a dev agent”pack.project is the pack’s other form, and is mutually exclusive with
pack.content. Instead of published content, it names an Arena project and
deploys the project’s current source to one hidden dev agent. The project
editor uses it to try the project out. It requires an enterprise operator.
{ "apiVersion": "deploy.omnia.altairalabs.ai/v1", "pack": { "project": { "id": "my-project" } }, "agents": [{ "providers": [{ "name": "default", "ref": "claude" }], "facades": [{ "type": "websocket", "dataPlane": false }], "idleTimeout": "30m" }]}A dev agent belongs to the signed-in user who deploys it, because it runs that user’s own workspace. The server takes the user from the caller’s verified token and never from the request body. An identity with no user is refused with 403.
The server derives every name from the project id and the user: the pack, the
agent and the PromptPackSource are all dev-<id>-<user>, where <user> is the
user’s pseudonym. When that is not a valid DNS label, the name is the
sanitized id plus a short hash of the id and user. pack.name and
agents[0].name may be omitted; if they are set, they must equal the derived
name, or the intent is rejected with 400. A dev intent therefore cannot name a
production agent or pack, or another user’s dev agent.
A project intent materializes a different object set, in this order:
| Object | Applied as | Notes |
|---|---|---|
| AgentRuntime | upsert, field-merged | promptPackRef names pack.name with no version. Carries the dev labels and the idle-timeout annotation. |
PromptPackSource (type project) | create, or adopt | Named dev-<id>-<user>. Owned by the AgentRuntime. |
| ToolRegistry | upsert | Unlike a published pack, a re-apply updates the spec, so an edited tool reaches the dev agent. Owned by the AgentRuntime. |
| Secret (tool credentials) | upsert | As for a published pack, owned by the ToolRegistry. |
| ToolPolicy | upsert | As for a published pack, and created already owned by the AgentRuntime. |
There is no ConfigMap and no PromptPack. The source materializes one live
PromptPack for pack.name, which it owns; until then the agent waits on its
PromptPackReady condition. The agent is applied first because everything
else is owned by it: when the idle reaper deletes the agent, garbage
collection removes the source, its live pack, and the tools with it.
The agent, the source and every object the intent synthesizes carry four labels. The source copies them to its live pack.
omnia.altairalabs.ai/dev: "true", which marks a dev agent and the objects behind it.omnia.altairalabs.ai/hidden: "true", which keeps them out of every list and picker. See Hidden resources.omnia.altairalabs.ai/arena-project: <id>, which the project editor uses to find its agent.omnia.altairalabs.ai/arena-user: <user>, the pseudonym of the user the dev agent belongs to.
The server sets these labels itself. An intent’s own labels cannot override
them.
A dev deploy never takes over an object it did not make. An existing
AgentRuntime, PromptPackSource, ToolRegistry, ToolPolicy or credential Secret
under a dev name is reused only when it carries omnia.altairalabs.ai/dev: "true" and this
project’s omnia.altairalabs.ai/arena-project and this user’s
omnia.altairalabs.ai/arena-user labels, or is already owned by this dev
agent. A PromptPackSource must also serve the same project and user as the
same pack. Anything else is refused and the deploy answers 409 Conflict,
without writing to it. A production object that happens to share the name is
never merged onto, repointed, or garbage-collected with the dev agent.
The same check covers one user opening a project twice at once. Both find no dev agent and both create one; the editor whose create loses re-checks the agent that won and updates it, so both applies succeed. If the object that appeared is not this project’s dev agent, the loser answers 409 instead.
Reading the response
Section titled “Reading the response”200 and 207 both return the same results array; the apply is best-effort
and per-resource, so a failure on one object does not abort the rest.
{ "succeeded": false, "results": [ { "kind": "ConfigMap", "name": "my-pack-content", "action": "created" }, { "kind": "PromptPack", "name": "my-pack", "action": "unchanged" }, { "kind": "AgentRuntime", "name": "support-bot", "action": "updated", "url": "https://omnia.example.com/console?workspace=acme&agent=support-bot" }, { "kind": "AgentRuntime", "name": "triage-bot", "action": "failed", "error": "..." } ]}succeededisfalseif any resource hasaction: "failed", and the status is then 207 Multi-Status. 200 means every resource applied.- 409 Conflict carries the same body when a project intent refused to take over an existing object (see Deploying a project to a dev agent).
- 409 Conflict also answers an agent with
create: truewhose name an AgentRuntime already has. The check runs before anything is written, so the refused deploy publishes no pack;resultsnames the agent. - 503 Service Unavailable, with
Retry-After, carries the same body when the project’s dev agent is still being deleted, as it is right after the idle reaper removes it. Nothing is written. Retry the same intent: once the agent is gone, the retry recreates it. - Through the dashboard’s
POST /deployments, a 409 or 503 answers{error, results}: the proxy’s error message, plus the operator’s per-resourceresults, which name the refused object. actionis one ofcreated,updated,unchanged,failed.- A result can carry an
errorwithoutaction: "failed": an unestablished owner reference on a pack ConfigMap, or credentials that could not apply to a pre-existing ToolRegistry (see Credentials). Neither flipssucceeded— an adapter must not treaterroralone as failure.
url may be absent, and must not be synthesized
Section titled “url may be absent, and must not be synthesized”ResourceResult.url is an operator-facing console link, set by the
dashboard (never the operator) from x-forwarded-host on the way back out.
It is absent in three cases: an unknown public address, a kind with no
console page (only AgentRuntime has one), and a resource that failed to
apply. Absent means “not known” — an adapter must show no link rather than
build one.
Rules that live only in validation
Section titled “Rules that live only in validation”The server enforces these structural rules at the door, returning 400. They are not expressible in the schema.
tools.refandtools.handlersare mutually exclusive: one references an existing registry, the other creates one.credentialsrequirestools.handlers. Withtools.refthe registry is operator-owned; its Secret references stay untouched.policy.toolBlocklistrequirestoolsand an enterprise operator.agents[].idleTimeout, when set, is a positive Go duration.agents[].create(optional, defaultfalse) says the deploy makes the agent and must not update an existing one of that name. It is checked at apply time, not here: an existing agent answers 409 (below).agents[].preserveUnset(optional, defaultfalse): updating an existing agent,memory,evalsandexternalAuththat the intent leaves empty keep the agent’s own values rather than being cleared. Without it those three are the intent’s outright, and omitting one removes it.pack.projectexcludespack.content,pack.version,pack.skillsandpack.skillsConfig, and requires an enterprise operator. Itsidmust match^[a-z0-9]([-a-z0-9_.]*[a-z0-9])?$, the PromptPackSource rule, and be at most 63 characters, so it can be a label value.- With
pack.project,pack.nameandagents[].nameare empty or equal to the deriveddev-<id>-<user>name. - With
pack.project, the intent has exactly one agent. That agent has anidleTimeout, norollout, and a singlewebsocketfacade withdataPlane: falseandmanagementPlanenotfalse. - With
pack.project,policy.toolBlocklistis allowed only withtools.handlers, never withtools.ref. A ToolPolicy selects a registry, not an agent, so a dev policy on a referenced registry would change the tools of every agent that uses it.
Beyond these, validation is structural only. Field-level validation stays
with the Kubernetes API server, and its rejections come back as a failed
result rather than a 400.
Credentials
Section titled “Credentials”credentials.data carries values, not names:
{ "credentials": { "data": { "GITHUB_TOKEN": "ghp_..." } }}The values come from the deploy environment: a pack declares
headers_from_env: [Authorization=GITHUB_TOKEN], and GITHUB_TOKEN lives in
the CI job.
The server:
- Writes a Secret it owns and names (
<pack>-tools-credentials), owned by the ToolRegistry whose handlers reference it and garbage-collected with it. Values never appear in any spec. - Preserves each map key verbatim as the Secret key. The key is the
environment-variable name the pack’s
headers_from_enventry references, and must match[-._a-zA-Z0-9]+. - Repoints a handler’s Secret reference only when that reference’s
keyappears incredentials.data. A reference to a key not supplied is left alone, matching theexternalAuth.clientKeysmodel: the Secret is operator-owned and pre-existing.
The migration caveat
Section titled “The migration caveat”A published deploy never updates an existing ToolRegistry (a project intent’s dev registry is the exception: it is updated). If one already exists for this pack, its persisted handlers keep whatever Secret references they were created with; this deploy’s rewrite touches only the in-memory copy.
The result carries an error and action: "unchanged", and the deploy
still succeeds. To re-materialize the registry with operator-owned
credentials, delete it and redeploy.
What a redeploy preserves
Section titled “What a redeploy preserves”An AgentRuntime is field-merged, not replaced: a deploy applies only what
the intent can express, and every other field belongs to whoever set it (the
dashboard, kubectl, or a controller). A drift test asserts the tier
classification exhaustively, so a new CRD field cannot silently join the
overwritten set.
| Tier | Behaviour | Fields |
|---|---|---|
| Preserved | A deploy has no opinion. | mode, inputSchema, outputSchema, outputFormat, framework, context, media, console, duplex, extraPodAnnotations, serviceGroup, privacyPolicyRef, podOverrides, sandbox, rateLimit; runtime.nodeSelector, tolerations, affinity, volumes, volumeMounts; facade port, drainTimeout, image, extraEnv, clientToolTimeout, a2a, mcp, expose, async; rollout candidate, stickySession, rollback, trafficRouting. |
| Intent-owned | Assigned wholesale; omission means “off”. Clearing externalAuth leaves the agent management-plane only, which fails closed. | providers, memory, evals, externalAuth, toolRegistryRef |
| Field-merged | The intent owns some sub-fields; the live object owns the rest. | promptPackRef, runtime, facades, rollout |
Facade type, managementPlane and dataPlane are intent-owned; handler
is always forced to runtime.
The omnia.altairalabs.ai/idle-timeout annotation is intent-owned too.
agents[].idleTimeout sets it, and an intent without one removes it, so an
agent deployed without an idle timeout is never reaped.
A merge that changes nothing is not written: the result is unchanged, and
the agent keeps its generation and does not roll. The same holds for the
ToolPolicy, the credential Secret and a dev ToolRegistry, whose comparison
allows for the defaults the API server fills in. The comparison reads the object fresh
from the API server, never from a cache. One user’s editor sessions can
apply the same intent at once without restarting the dev agent.
Rollout interaction
Section titled “Rollout interaction”- An intent with no
rolloutblock leaves the live rollout untouched, including a manually driven canary. - A rollout under a version trigger keeps its live pack pin; the version-triggered rollout controller owns advancing it.
- An intent carrying a
rolloutwith no trigger clears the trigger and takes the pin from the intent, exiting trigger mode. - A track follower keeps following:
versionandtrackare mutually exclusive under the CRD’s own validation, and no deploy ever writestrack. - An intent declaring
rollout.triggerenters version-triggered mode: the track is cleared and the pin comes from the intent, unless the agent is already in trigger mode, in which case the live pin stays with the controller.
The rollout is merged before the pack ref, so pin ownership is decided from the rollout the deploy leaves behind.
Checking a plan before you deploy
Section titled “Checking a plan before you deploy”POST /api/v1/workspaces/{workspace}/deploy/plan is a separate, read-only
endpoint served directly by the operator — not proxied through the
dashboard, and not versioned by apiVersion the way an intent is. It
resolves a kit’s provider, tool-registry and skill bindings against the
caller’s workspace namespace and reports what each one would bind to,
without creating or updating anything.
The route sits on the operator’s deploy API listener beside
POST .../deployments, behind the same guard: the caller must be in-cluster
and present a dashboard-minted identity JWT (audience omnia-operator) whose
role in the workspace is editor or higher. The workspace namespace comes from
that token, never from the body. The dashboard proxies it at
POST /api/workspaces/{workspace}/deploy/plan (editor), forwarding the body
and the result unchanged; the project editor plans through it before every
dev deploy.
{ "arenaConfig": "<the kit's arena config file, YAML or JSON, as a string>", "deployConfig": { "...": "..." }}deployConfig is an already-merged deploy.config object; when both fields
are set, deployConfig wins and arenaConfig is ignored. A
"deployConfig": null counts as unset, so a client that always sends both
fields still gets its arenaConfig planned.
arenaConfig is accepted in either of two shapes. The endpoint reads
spec.deploy when present, otherwise top-level deploy:
| Shape | Deploy block | Sent by |
|---|---|---|
| The kit’s arena config file, a k8s-style manifest | spec.deploy | a caller holding the kit file |
| The flattened arena config | top-level deploy | PromptArena’s deploy adapter, which serialises the manifest’s spec |
An arenaConfig with neither spec.deploy nor deploy (a new project’s
arena config, before its author adds bindings) is a 200 with no bindings
and a single no_deploy_config diagnostic, not a 400. Only the list
form of providers and skills is accepted: a name-keyed map of
providers or a bare-string list of skills is a 400, not a silent
best-effort parse.
The response is 200 whether or not it carries diagnostics — an unresolvable ref is the answer this endpoint exists to give, not a request failure:
{ "providers": [ { "name": "default", "ref": "claude-prod", "role": "llm", "resolved": false } ], "toolRegistry": { "mode": "bind", "name": "support-tools", "resolved": true }, "skills": [ { "source": "policies", "resolved": true } ], "diagnostics": [ { "code": "provider_not_ready", "path": "deploy.config.providers[0].ref", "message": "provider \"claude-prod\" is not ready (phase: Pending)", "remediation": "wait for Provider claude-prod to become Ready (phase: Pending), or change the ref" } ]}providers, skills and diagnostics are always arrays, never null —
an empty kit reports empty lists, not an absent field. Every binding appears
in input order, with the provider role defaulted to llm.
resolvedon each provider and skill istrueonly when that binding drew no diagnostic. For a provider, that means the binding is well-formed, the Provider exists, its phase isReadyor empty, and its role matches. For a skill, it means the source is well-formed and the SkillSource exists.toolRegistry.modeisnone,bind, orcreate.nameis set only forbind. Increatemode,toolscarries the kit’s inline tool definitions verbatim.toolRegistry.resolvedistruefornoneandcreate, and forbindonly when the ToolRegistry exists.
Each diagnostic’s path is relative to the deploy block:
deploy.config.providers[0].ref. In a kit’s arena config file the same field
is spec.deploy.config.providers[0].ref.
Lookups read the operator’s informer cache rather than the API server. A Provider, ToolRegistry or SkillSource created a moment before the call can still report as not found until the cache catches up.
Diagnostic codes
Section titled “Diagnostic codes”| Code | Meaning |
|---|---|
invalid_binding | The binding’s shape is wrong before any lookup: an empty or malformed ref, an empty or duplicate provider name, a provider name that does not match ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ (the AgentRuntime CRD’s rule), an unsupported provider role, or a malformed skill source or tool_registry_ref. |
provider_not_found | The ref names no Provider in this workspace. |
provider_not_ready | The Provider exists but its phase is not Ready. |
provider_role_mismatch | The Provider exists and is usable, but its role (spec.role, default llm) differs from the binding’s role. The AgentRuntime controller fails a runtime on this, so the plan reports it first. The path is deploy.config.providers[i].role. |
tool_registry_not_found | tool_registry_ref names no ToolRegistry in this workspace. |
skill_source_not_found | A skill’s source names no SkillSource in this workspace. |
no_deploy_config | The arenaConfig has no deploy block at all (neither spec.deploy nor deploy). It is the plan’s only diagnostic, with path deploy; the remediation gives the complete minimal block: spec.deploy.provider: omnia and a spec.deploy.config.providers binding. |
no_deploy_provider | The arenaConfig has a deploy block that names no provider. The bindings still plan, but PromptArena’s compiler refuses the project (spec.deploy: provider is required); set spec.deploy.provider: omnia. The path is deploy.provider. |
no_provider_bindings | deploy.config.providers is empty. When the kit’s providers live only under deploy.environments.* instead, the remediation names which ones — this endpoint reads only deploy.config. |
A 400 (not a diagnostic) means the request itself was malformed: neither
arenaConfig nor deployConfig was set, or one of them failed to parse into its expected shape. The message
names the key, the expected shape and the decoder’s own error, such as a
role sent as a number.
See also
Section titled “See also”api/openapi/openapi.yaml— the schema of record for every field.- AgentRuntime reference — the CRD an agent intent becomes.
- ToolRegistry reference — the CRD
tools.handlersbecomes.