Skip to content

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.

GET /api/workspaces/{workspace}/deploy-profile
POST /api/workspaces/{workspace}/deployments

Both 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.

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.

A single POST creates or updates this object set, in this order:

ObjectApplied asNotes
ConfigMap (pack content)create-onlyHolds the raw pack.json; treated as opaque bytes. Owned by the PromptPack for garbage collection.
PromptPackcreate-onlyAn existing pack of the same name is reported unchanged, never rewritten.
ToolRegistrycreate-onlyOnly when tools.handlers is given. See Credentials.
Secret (tool credentials)upsertOnly when credentials is given. Owned by the ToolRegistry. Upsert because credentials rotate.
ToolPolicyupsertOnly when policy.toolBlocklist is given. Enterprise only.
AgentRuntime (one per agent)upsert, field-mergedSee 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 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.

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:

ObjectApplied asNotes
AgentRuntimeupsert, field-mergedpromptPackRef names pack.name with no version. Carries the dev labels and the idle-timeout annotation.
PromptPackSource (type project)create, or adoptNamed dev-<id>-<user>. Owned by the AgentRuntime.
ToolRegistryupsertUnlike a published pack, a re-apply updates the spec, so an edited tool reaches the dev agent. Owned by the AgentRuntime.
Secret (tool credentials)upsertAs for a published pack, owned by the ToolRegistry.
ToolPolicyupsertAs 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.

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": "..." }
]
}
  • succeeded is false if any resource has action: "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: true whose name an AgentRuntime already has. The check runs before anything is written, so the refused deploy publishes no pack; results names 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-resource results, which name the refused object.
  • action is one of created, updated, unchanged, failed.
  • A result can carry an error without action: "failed": an unestablished owner reference on a pack ConfigMap, or credentials that could not apply to a pre-existing ToolRegistry (see Credentials). Neither flips succeeded — an adapter must not treat error alone 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.

The server enforces these structural rules at the door, returning 400. They are not expressible in the schema.

  • tools.ref and tools.handlers are mutually exclusive: one references an existing registry, the other creates one.
  • credentials requires tools.handlers. With tools.ref the registry is operator-owned; its Secret references stay untouched.
  • policy.toolBlocklist requires tools and an enterprise operator.
  • agents[].idleTimeout, when set, is a positive Go duration.
  • agents[].create (optional, default false) 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, default false): updating an existing agent, memory, evals and externalAuth that 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.project excludes pack.content, pack.version, pack.skills and pack.skillsConfig, and requires an enterprise operator. Its id must 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.name and agents[].name are empty or equal to the derived dev-<id>-<user> name.
  • With pack.project, the intent has exactly one agent. That agent has an idleTimeout, no rollout, and a single websocket facade with dataPlane: false and managementPlane not false.
  • With pack.project, policy.toolBlocklist is allowed only with tools.handlers, never with tools.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.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_env entry references, and must match [-._a-zA-Z0-9]+.
  • Repoints a handler’s Secret reference only when that reference’s key appears in credentials.data. A reference to a key not supplied is left alone, matching the externalAuth.clientKeys model: the Secret is operator-owned and pre-existing.

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.

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.

TierBehaviourFields
PreservedA 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-ownedAssigned wholesale; omission means “off”. Clearing externalAuth leaves the agent management-plane only, which fails closed.providers, memory, evals, externalAuth, toolRegistryRef
Field-mergedThe 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.

  • An intent with no rollout block 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 rollout with no trigger clears the trigger and takes the pin from the intent, exiting trigger mode.
  • A track follower keeps following: version and track are mutually exclusive under the CRD’s own validation, and no deploy ever writes track.
  • An intent declaring rollout.trigger enters 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.

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:

ShapeDeploy blockSent by
The kit’s arena config file, a k8s-style manifestspec.deploya caller holding the kit file
The flattened arena configtop-level deployPromptArena’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.

  • resolved on each provider and skill is true only when that binding drew no diagnostic. For a provider, that means the binding is well-formed, the Provider exists, its phase is Ready or empty, and its role matches. For a skill, it means the source is well-formed and the SkillSource exists.
  • toolRegistry.mode is none, bind, or create. name is set only for bind. In create mode, tools carries the kit’s inline tool definitions verbatim. toolRegistry.resolved is true for none and create, and for bind only 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.

CodeMeaning
invalid_bindingThe 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_foundThe ref names no Provider in this workspace.
provider_not_readyThe Provider exists but its phase is not Ready.
provider_role_mismatchThe 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_foundtool_registry_ref names no ToolRegistry in this workspace.
skill_source_not_foundA skill’s source names no SkillSource in this workspace.
no_deploy_configThe 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_providerThe 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_bindingsdeploy.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.