# ToolRegistry CRD

The ToolRegistry custom resource defines tool handlers available to AI agents. Handlers can expose one or more tools and come in two categories:

- **Self-describing** (MCP, OpenAPI): Automatically discover tools at runtime
- **Explicit** (HTTP, gRPC, client): Require a tool definition with name, description, and input schema

:::note[Endpoints live inside the type-specific config block]
There is **no** handler-level `endpoint` field. The endpoint for a handler lives
inside its type block: `httpConfig.endpoint`, `grpcConfig.endpoint`,
`mcpConfig.endpoint`, or `openAPIConfig.specURL`. The CRD uses strict schema
validation, so an unknown field such as a top-level `endpoint:` is **rejected**
by the API server.
:::

## API version

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: ToolRegistry
```

**Short name:** `tr` (e.g. `kubectl get tr`).

## Spec fields

### `handlers`

List of handler definitions (at least one is required). Each handler connects to
a tool source and exposes one or more tools.

```yaml
spec:
  handlers:
    - name: calculator
      type: http
      httpConfig:
        endpoint: https://api.example.com/calculate
        method: POST
      tool:
        name: calculate
        description: "Perform mathematical calculations"
        inputSchema:
          type: object
          properties:
            expression:
              type: string
          required: [expression]
```

## Handler types

| Type | Category | Description |
|------|----------|-------------|
| `http` | Explicit | HTTP REST endpoint |
| `grpc` | Explicit | gRPC service using the Omnia Tool protocol |
| `mcp` | Self-describing | Model Context Protocol server |
| `openapi` | Self-describing | OpenAPI/Swagger-documented service |
| `client` | Explicit | Browser-executed tool (runs in the connected client) |
| `platform` | Self-describing | Served by the operator itself, no backend endpoint. See [Platform handler](#platform-handler-self-describing) |

### Handler definition

Common fields for all handler types:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Unique handler name (`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`, max 63 chars) |
| `type` | string | Yes | Handler type (`http`, `grpc`, `mcp`, `openapi`, `client`, `platform`) |
| `tool` | object | Conditional | Tool definition — required for `http`, `grpc`, and `client`. Not permitted on `platform` handlers |
| `httpConfig` | object | Conditional | Required when `type: http`. Not permitted on `platform` handlers |
| `grpcConfig` | object | Conditional | Required when `type: grpc`. Not permitted on `platform` handlers |
| `mcpConfig` | object | Conditional | Required when `type: mcp`. Not permitted on `platform` handlers |
| `sandbox` | object | No | `{name, port}`: serves the handler from one of the registry's [sandboxes](#sandboxes). Not permitted on `client` or `platform` handlers. See [Handlers on a sandbox](#handlers-on-a-sandbox) |
| `openAPIConfig` | object | Conditional | Required when `type: openapi`. Not permitted on `platform` handlers |
| `clientConfig` | object | No | `{consentMessage, categories}` for a `client` handler. See [Client handler](#client-handler-browser-executed) |
| `platform` | object | Conditional | Required when `type: platform`, and not permitted for any other type. See [Platform handler](#platform-handler-self-describing) |
| `auth` | object | No | How the runtime authenticates to the backend (see [Authenticating tools](/how-to/tools/authenticate-tools/)). Not permitted on `platform` handlers |
| `delegation.enabled` | bool | No | Default `false`. When `true`, every call to this handler carries an `X-Omnia-Delegation` header: a short-lived JWT signed by the operator naming the end user, agent and workspace, verifiable against the operator's JWKS. Rejected on `client` handlers. The whole `delegation` field is rejected on `platform` handlers — a platform tool call always carries a delegation token, scoped to the workspace rather than the handler |
| `timeout` | string | No | Per-invocation wall-clock timeout. Defaults to `30s`. The runtime sizes each turn's execution ceiling to 2m plus the longest handler timeout, so a long timeout, such as 5m for a sandbox cold start, is not cut short by the turn |

:::caution[There is no `retries` field]
Retry behaviour is configured per transport via `httpConfig.retryPolicy`,
`grpcConfig.retryPolicy`, `mcpConfig.retryPolicy`, and
`openAPIConfig.retryPolicy`. See [HTTP retry policy](#http-retry-policy).
:::

### Tool definition (for explicit handlers)

`http`, `grpc`, and `client` handlers require a `tool` definition:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `tool.name` | string | Yes | Tool name exposed to the LLM (`^[a-z][a-z0-9_]*$`, max 64) |
| `tool.description` | string | Yes | Human-readable description |
| `tool.inputSchema` | object | Yes | JSON Schema for input parameters |
| `tool.outputSchema` | object | No | JSON Schema for output (optional) |

## HTTP handler

The endpoint URL lives in `httpConfig.endpoint` (**required**):

```yaml
- name: search-api
  type: http
  httpConfig:
    endpoint: https://api.example.com/search
    method: POST
    headers:
      Content-Type: application/json
    contentType: application/json
  tool:
    name: search
    description: "Search the knowledge base"
    inputSchema:
      type: object
      properties:
        query:
          type: string
          description: "Search query"
        limit:
          type: integer
          default: 10
      required: [query]
  timeout: "30s"
```

### HTTP configuration options

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `endpoint` | string | — | **Required.** HTTP endpoint URL, or a path on the sandbox port when the handler sets [`sandbox`](#handlers-on-a-sandbox) |
| `method` | string | `POST` | HTTP method |
| `headers` | map | — | Additional HTTP headers |
| `headersFromSecret` | map | — | Map of HTTP header name → `secretRef` (`{name, key}`). Value resolved from the Secret at call time; never stored in the CRD or the tools ConfigMap. Not for a header the `auth` stanza owns (`Authorization`, or a custom `auth.workloadIdentity.header`). See [Advanced HTTP tools](/how-to/tools/advanced-http-tools/). |
| `contentType` | string | `application/json` | Content-Type header |
| `retryPolicy` | object | — | Retry behaviour. See [HTTP retry policy](#http-retry-policy) |
| `authType` | string | — | **Deprecated.** Use the handler-level `auth` stanza instead. Auth type (`bearer` or `basic`). |
| `authSecretRef` | object | — | **Deprecated.** Use the handler-level `auth` stanza instead. Reference to a Secret holding the credential. |

The HTTP handler also supports request/response shaping fields —
`urlTemplate`, `queryParams`, `headerParams`, `staticQuery`, `staticBody`,
`bodyMapping`, `responseMapping`, and `redact`. These are **not** exposed in the
dashboard UI; see [Advanced HTTP tools](/how-to/tools/advanced-http-tools/).

### HTTP retry policy

`httpConfig.retryPolicy` and `openAPIConfig.retryPolicy` share this shape. With
no `retryPolicy`, a call is attempted once.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `maxAttempts` | int | — | **Required.** Total attempts including the first, 1 to 10. `1` disables retries |
| `initialBackoff` | duration | `100ms` | Delay before the first retry |
| `backoffMultiplier` | string | `"2.0"` | Decimal multiplier between retries, at least `1.0` |
| `maxBackoff` | duration | `30s` | Upper bound on the delay. Must be at least `initialBackoff`; the controller rejects the handler otherwise |
| `retryOn` | []int | `[408, 429, 500, 502, 503, 504]` | HTTP status codes that trigger a retry. An empty list disables status-based retry |
| `retryOnNetworkError` | bool | `true` | Retry connection, DNS and timeout errors, which occur before a status code arrives |
| `respectRetryAfter` | bool | `true` | On a retried response, wait at least the `Retry-After` value (seconds or HTTP date), capped at `maxBackoff` |

Each delay grows by `backoffMultiplier` with about 10% jitter. `grpcConfig.retryPolicy`
has the same backoff fields plus `retryableStatusCodes`, a list of gRPC status
code names (default `UNAVAILABLE`, `DEADLINE_EXCEEDED`, `RESOURCE_EXHAUSTED`).
`mcpConfig.retryPolicy` has only the four backoff fields.

### Authenticating tools

Authentication is configured with the **handler-level `auth` stanza** (a sibling
of `httpConfig`/`openAPIConfig`/…), so the same shape applies across handler
types:

```yaml
handlers:
  - name: my-api
    type: http
    httpConfig:
      endpoint: https://api.example.com
    auth:
      type: bearer              # none | bearer | basic | serviceAccount | workloadIdentity
      secretRef:
        name: my-tool-credentials   # a Kubernetes Secret in the same namespace
        key: token                  # for basic, the value is "username:password"
    tool:
      name: my_tool
      description: "Call the authenticated API"
      inputSchema:
        type: object
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `auth.type` | string | `none` | Authentication mechanism: `none`, `bearer`, `basic`, `serviceAccount`, or `workloadIdentity`. |
| `auth.secretRef` | object | - | Secret holding the credential. `bearer`/`basic` need this or `auth.connectionRef`. |
| `auth.connectionRef` | object | - | `{name, key}`: reads the `bearer`/`basic` credential from key `key` of a [Connection](/reference/core/connection/)'s Secret instead. Not with `secretRef`, and not on a handler that addresses a sandbox. See [Reading the credential from a Connection](#reading-the-credential-from-a-connection). |
| `auth.serviceAccount.audience` | string | - | Audience the projected ServiceAccount token binds to (required for `serviceAccount`). |
| `auth.workloadIdentity` | object | - | Hosted same-cloud identity: `cloud` (`azure`), `audience` (required), `header` (default `Authorization`). Required for `workloadIdentity`. |

The `auth` stanza applies to **http, openapi, grpc, and mcp** handlers (the
runtime attaches the credential as an HTTP `Authorization` header, gRPC
`authorization` metadata, or an MCP transport header).

- **`bearer` / `basic`** — the operator resolves `secretRef` into an
  operator-managed `<agentruntime>-tool-secrets` Secret, mounted read-only into
  the runtime. The token value never enters the tools ConfigMap.
- **`serviceAccount`** — the operator projects an audience-bound Kubernetes
  ServiceAccount token into the runtime; the tool backend validates it via
  TokenReview. Sent as `Authorization: Bearer <token>`.
- **`workloadIdentity`** — resolved by the runtime under the pod's ambient
  Azure identity (core); `cloud` must be `azure`. The runtime acquires a token
  for `audience` and sets it on `header` (default `Authorization`). Supported on
  **http, grpc, and mcp (sse / streamable-http) and openapi** handlers; any
  other handler type with `workloadIdentity` fails the AgentRuntime reconcile.
  The token is acquired per call, so it survives token expiry. All WIF tools
  share the pod's one identity, so that identity must be granted every WIF
  tool's API.

For a **secret header other than `Authorization`** (a second API key or signing
token), use `httpConfig.headersFromSecret` — it reuses the same
`<agentruntime>-tool-secrets` companion Secret and never-in-ConfigMap guarantee.
A key there that collides with a header the `auth` stanza owns is rejected:
`Authorization`, or the custom `auth.workloadIdentity.header` when one is set.

A missing Secret/key or an unsupported type fails the AgentRuntime reconcile — it does not silently send an unauthenticated request.
A `workloadIdentity` handler whose token cannot be acquired at call time fails
that tool call rather than calling the backend unauthenticated.

See [Authenticate tools](/how-to/tools/authenticate-tools/#workloadidentity-azure)
for Azure workload-identity cluster setup and for a task-oriented walkthrough
(creating the credential Secret and verifying the mounted
`<agentruntime>-tool-secrets`).

#### Reading the credential from a Connection

When the runtime and a sandbox both reach a service, `auth.connectionRef` lets
the handler use the credential of the sandbox's
[Connection](/reference/core/connection/) instead of a second copy in its own
Secret. Rotating the Connection's Secret, or deleting the Connection, then
reaches both.

```yaml
handlers:
  - name: crm
    type: http
    httpConfig:
      endpoint: https://crm.example.com/v1/lookup
    auth:
      type: bearer
      connectionRef:
        name: crm-shared   # a Connection in the AgentRuntime's namespace
        key: token         # a key of the Connection's Secret
```

`auth.type` still says how the credential is sent. The operator gives it to the
handler only when all of these hold:

- the Connection exists and its credential kind is `secret`;
- the Secret it names has the key;
- every URL the handler sends the credential to is `https` on port 443, at a host
  the Connection lists. Those URLs are `httpConfig.endpoint` and `urlTemplate`;
  `mcpConfig.endpoint`; `openAPIConfig.specURL` and `baseURL` (an openapi
  handler must set `baseURL`); and `grpcConfig.endpoint` (a grpc handler must
  set `tls`).

The `HandlerConnectionsValid` condition reports the result. When a check
fails, for example because the Connection or its Secret was deleted, the
operator removes the handler's key from `<agentruntime>-tool-secrets`. It does
not keep the last value. The runtime then refuses each call to that handler
instead of calling unauthenticated. A Connection is looked up in the AgentRuntime's namespace,
the same as a `secretRef`. `HandlerConnectionsValid` checks it in the
registry's namespace.

:::note[Deprecated: `authType` / `authSecretRef`]
The per-config `authType` and `authSecretRef` fields on `httpConfig`/`openAPIConfig`
are deprecated in favour of the `auth` stanza. They still work and are normalized
into it, but setting **both** a handler `auth` stanza and a legacy
`authType`/`authSecretRef` on the same handler is rejected.
:::

## GRPC handler

The endpoint (`host:port`) lives in `grpcConfig.endpoint`, required unless the
handler addresses a [sandbox](#handlers-on-a-sandbox):

```yaml
- name: grpc-tools
  type: grpc
  grpcConfig:
    endpoint: tool-service.tools.svc.cluster.local:50051
    tls: false
    tlsInsecureSkipVerify: false
  tool:
    name: process_data
    description: "Process data via gRPC"
    inputSchema:
      type: object
      properties:
        data:
          type: string
      required: [data]
```

### GRPC configuration options

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `endpoint` | string | — | gRPC server address (`host:port`). Required unless the handler sets [`sandbox`](#handlers-on-a-sandbox), and left unset when it does |
| `tls` | bool | `false` | Enable TLS |
| `tlsCertPath` | string | — | Path (in the runtime container) to a client certificate for mutual TLS; used only together with `tlsKeyPath` |
| `tlsKeyPath` | string | — | Path to the client certificate's private key |
| `tlsCAPath` | string | — | Path to a CA bundle for verifying the server |
| `tlsInsecureSkipVerify` | bool | `false` | Skip TLS verification |
| `retryPolicy` | object | — | Retry behaviour (per gRPC status code) |

The backend behind `grpcConfig.endpoint` must implement the **Omnia Tool
protocol** defined by `api/proto/tools/v1/tools.proto`
(`ToolService`), not an arbitrary gRPC API. See
[Tool backend protocol](/reference/platform/tool-backend-protocol/) for the
full contract. The controller requires a `tool` definition on a `grpc`
handler, so the backend needs `Execute` only.

## MCP handler (self-describing)

Model Context Protocol handlers automatically discover tools from the MCP server. No `tool` definition is required.

**SSE Transport** (connect to MCP server via Server-Sent Events):

```yaml
- name: mcp-server
  type: mcp
  mcpConfig:
    transport: sse
    endpoint: http://mcp-server.tools.svc.cluster.local:8080/sse
```

**Streamable HTTP Transport**:

```yaml
- name: mcp-http
  type: mcp
  mcpConfig:
    transport: streamable-http
    endpoint: http://mcp-server.tools.svc.cluster.local:8080/mcp
```

There is no stdio transport. An MCP server reaches the runtime over the network
or not at all — the runtime image is `distroless/static`, so there is no
subprocess to spawn, and stdio carried no caller identity. To give an agent
local tooling including a shell, use a
[codegen sandbox](/explanation/security/codegen-sandbox/).

### MCP configuration options

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `transport` | string | Yes | `sse` or `streamable-http` |
| `endpoint` | string | Yes | Server URL, or a path on the sandbox port when the handler sets [`sandbox`](#handlers-on-a-sandbox) |
| `toolFilter` | object | No | `allowlist` / `blocklist` of tool names to expose |
| `retryPolicy` | object | No | Retry behaviour for CallTool failures |

`auth` is supported on both transports: the credential is attached as a
transport header.

Reconcile rejects an `endpoint` whose host is a link-local or cloud-metadata
address (a literal address or a well-known metadata hostname) and sets
`HandlersValid=False`. In-cluster Service names and private addresses are
allowed.

A non-sandbox MCP handler holds one session for the life of the pod. When that
session is lost, the runtime re-dials, re-lists the handler's tools and retries
the call once, whether or not `retryPolicy` is set. A session that ends is
re-dialled in the background with capped backoff.

## OpenAPI handler (self-describing)

OpenAPI handlers automatically discover tools from an OpenAPI/Swagger specification. Each operation becomes a tool.

```yaml
- name: petstore
  type: openapi
  openAPIConfig:
    specURL: https://petstore.swagger.io/v2/swagger.json
    baseURL: https://petstore.swagger.io/v2
    operationFilter:
      - getPetById
      - findPetsByStatus
```

### OpenAPI configuration options

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `specURL` | string | Yes | URL to an OpenAPI v2 or v3 spec in JSON. A YAML spec fails to parse |
| `baseURL` | string | No | Override the base URL from spec. A path on the sandbox port when the handler sets [`sandbox`](#handlers-on-a-sandbox) |
| `operationFilter` | []string | No | Limit to specific operation IDs |
| `headers` | map | No | Additional headers for requests |
| `retryPolicy` | object | No | Retry behaviour (uses the HTTP retry policy shape) |
| `authType` | string | No | **Deprecated.** Use the handler-level `auth` stanza instead. |
| `authSecretRef` | object | No | **Deprecated.** Use the handler-level `auth` stanza instead. |

## Client handler (browser-executed)

`client` handlers are executed by the connected browser client over the
WebSocket facade, not by the runtime. They take an explicit `tool` definition
like HTTP/gRPC handlers. See
[Client-side tools](/how-to/tools/client-tools/).

```yaml
- name: geolocation
  type: client
  tool:
    name: get_location
    description: "Read the user's current location from the browser"
    inputSchema:
      type: object
  clientConfig:
    consentMessage: "Share your location with the agent?"
    categories: [location]
```

`clientConfig.consentMessage` and `clientConfig.categories` are sent on each
`tool_call` as `consent_message` and `categories`. Each one that is set
replaces the value the PromptPack tool declares.

## Platform handler (self-describing)

`platform` handlers have no backend to dial: the operator serves their tools
itself, over its own REST API. The handler names which platform service it
exposes: `discovery`, the workspace catalog, or `arena`, real-provider runs of
the project mounted in the caller's sandbox.

```yaml
- name: catalog
  type: platform
  platform:
    service: discovery
```

### Platform configuration options

| Field | Type | Required | Description |
|-------|------|----------|--------------|
| `service` | string | Yes | The platform service this handler exposes: `discovery` or `arena`. |

The CRD rejects a `platform` handler that also sets `tool`, `httpConfig`,
`mcpConfig`, `grpcConfig`, `openAPIConfig`, `clientConfig`, `sandbox`, `auth`
or `delegation`. A platform handler has no address, no declared tool, and its
delegation token is implied rather than author-configured.

A `service: discovery` handler renders three tools, self-described like an
MCP or OpenAPI handler. Discovery is search-first: an agent calls
`catalog_overview` to see what exists, `catalog_search` to find candidates by
what it needs, and `catalog_get` for full detail on the ones it picked.

| Tool | Arguments | Returns |
|------|-----------|---------|
| `catalog_overview` | none | A count per kind (including `skill`), and the groups (tool registries, skill sources) an agent can narrow a search by, each with a count and readiness |
| `catalog_search` | `query?`, `kinds?` (comma-separated, e.g. `tool,skill`), `group?` (a group id from `catalog_overview`, e.g. `toolRegistry:billing`), `readyOnly?` (`"true"`/`"false"`), `limit?` (default 10, max 50), `cursor?` | A ranked page of compact results (`id`, `kind`, `name`, `group`, a description truncated to 200 characters, `ready`) and a `nextCursor` when more remain. No query browses every entry in id order |
| `catalog_get` | `ids` (required, comma-separated, at most 20, exactly as `catalog_search` returned them) | Full detail per found id (tool schema, A2A card, provider pricing, a skill's allowed tools, and so on); unknown or malformed ids come back in `missing` with a reason, never as a failure of the whole call |

Every argument is a string or a number, never an array: the platform handler's
rendered HTTP tools send GET arguments as query parameters formatted with
`%v`, which would render an array as `[a b]`.

Every result and entry carries a stable id: `tool:<registry>/<name>` and
`skill:<source>/<name>` for the two per-group kinds, `<kind>:<name>` for every
other kind (provider, agent, skillSource, connection, sandboxClass,
promptPack, memorySchema). A group id is `toolRegistry:<name>` or
`skillSource:<name>`. Skills are a searchable kind: `catalog_search` reads
them from a per-`SkillSource` index the SkillSource reconciler writes after
each sync, so a skill is findable by name or description without a body
fetch — `catalog_get`'s skill detail is `{source, name, description,
allowedTools, relPath}`, never the `SKILL.md` text. `allowedTools` is
returned detail only; the ranker does not score against it.

`readyOnly` is a string (`"true"`/`"false"`), not a JSON boolean: over MCP the
tool's declared argument type is `string`, so a bare JSON `true`/`false` is
refused rather than coerced.

An agent lists these three tool names in its PromptPack exactly like any other
ToolRegistry tool — a platform handler carries no implicit exemption from the
pack's tool allowlist. Each call reaches the operator's own catalog over
`GET /api/v1/workspaces/{workspace}/catalog/{overview,search,get}`,
authenticated per call by a delegation token whose audience is the agent's
workspace rather than this handler, so it cannot be replayed against another
workspace. Errors follow the same shape whichever way the catalog
is reached: an unknown kind in `kinds` is `400` naming the valid kinds, a
`group` that is not a `toolRegistry:<name>` or `skillSource:<name>` id is
`400`, a cursor reused with a different query or filter set is `400`, and
more than 20 ids to `catalog_get` is `400`.

Every caller admitted through this handler sees the same catalog — an
ordinary conversation, a scheduled function fire, and a session with an
externally verified end user all get the same viewer-level view. The handler
being present in an agent's own ToolRegistry is the opt-in: no separate grant
object names it.

### `service: arena`

```yaml
- name: arena
  type: platform
  platform:
    service: arena
```

A `service: arena` handler renders three tools that let an agent run the
arena project mounted in its own sandbox on the workspace's real Providers.
No provider credential enters the sandbox: the run is an ordinary `ArenaJob`,
executed by the arena controller's workers.

| Tool | Arguments | Returns |
|------|-----------|---------|
| `arena_check_project` | none | `{project, problems}`: file validation, `deploy.config` binding and tool-wiring problems (a declared tool no prompt allows, or a scenario asserting a call its prompt cannot make), each `{source, severity, file?, line?, column?, message, remediation?}`. It checks the declaring user's working clone as it stands, uncommitted changes included |
| `arena_start_run` | `kind` (required; only `evaluation`), `scenarios?` and `providers?` (comma-separated) | `{runId, job, message?}`. The run executes the commit the declaring user's working clone has checked out, and only once it is pushed (see below). Without `providers`, providers resolve from that commit's `deploy.config` bindings, and any binding error refuses the run (`422`, with the problems). `providers` names workspace Providers that must exist and be Ready |
| `arena_get_run` | `runId` (required), `waitSeconds?` (at most 60) | The run's phase and progress, and once it finishes a compact result: pass rate (a percentage, 0–100), item counts, up to 50 scenario × provider cells, 10 failed assertions and 5 distinct errors |

`arena_start_run` is a `POST` with a JSON body; the other two are `GET`s. Each
call reaches `/api/v1/workspaces/{workspace}/platform/arena/{check|start|get}`
with a delegation token minted for that one tool, and a token minted for one
arena tool is refused by the others.

The project is never an argument. It is the one mounted in the calling
session's SandboxClaim — a claim the operator itself wrote, as recorded by its
`ProvenanceVerified` condition. A session with no project mounted gets `409`
"no project is mounted in this session", and `arena_get_run` on a run of
another project gets `403`. `runId` may be the job name `arena_start_run`
returned before its run existed; `arena_get_run` follows it to the run. Every
error body is `{error, problems?}`.

The working clone is the one the claim's `spec.declaredBy` names: the
verified user whose session declared the mount, never anything in the request.
`arena_start_run` builds only from pushed work, because the agent never
pushes and the person's push is the approval a real-provider run waits for.
Before it starts anything, it fetches a hosted project's repository into the
clone and refuses with `409`:

| `error` | When |
|---|---|
| `commit and push first; main has 2 uncommitted changes` | The clone has uncommitted or untracked changes |
| `commit and push first; main is 1 ahead` | The branch has commits the repository does not |
| `commit and push first; main has no commits` | The branch has no commits yet |
| `commit and push first; HEAD is not on a branch; switch back to one` | HEAD is detached |
| `the workspace is busy: ...` | Another git operation holds the clone's `index.lock` |

A clone that is behind but not ahead runs its own HEAD, which is a pushed
commit. The job's `spec.ref` is that commit's full SHA.

People reach the same operations through user routes on the deploy API,
authorised by their workspace role rather than a session:
`GET .../arena/projects/{project}/problems` (viewer),
`POST .../arena/projects/{project}/runs` (editor) and
`GET .../arena/runs/{run}?wait=` (viewer).

:::note[The handler renders no tools without the operator's catalog API]
Rendering a `platform` handler needs the operator's `--platform-api-url` flag
(set by the chart when the deploy API is enabled) and a `Workspace` for the
handler's namespace. Without either, the AgentRuntime controller skips the
handler and logs the reason — it does not fail the reconcile, and the
registry's other handlers are unaffected.
:::

## Sandboxes

`spec.sandboxes` declares the per-session
[codegen sandboxes](/explanation/security/codegen-sandbox/) this registry's
handlers are served from, at most 16, keyed by `name`. A sandbox is a backend:
its class says which ports it has, and the handlers that name it say what it
serves. The operator claims one for each session that calls a handler on it.
The AgentRuntime that uses the registry must set `spec.sandbox.enabled`.

```yaml
spec:
  sandboxes:
    - name: workspace
      class:
        name: codegen-default
        kind: ClusterSandboxClass
      allocation: lazy
      maxInstances: 1
      targetPath: projects/checkout-service
      connections:
        - role: vcs
          connection: github-shared
  handlers:
    - name: workspace
      type: mcp
      sandbox:
        name: workspace     # a sandbox in spec.sandboxes
        port: mcp           # a port its class declares
      mcpConfig:
        transport: sse
        endpoint: /sse      # a path on that port
        toolFilter:
          allowlist: [Read, Write, Edit, Glob, Grep, Bash]
```

### Sandbox fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Unique sandbox name (`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`, max 63 chars). Handlers name it in `sandbox.name`, and it is the first part of each claim's handle, `<sandbox>/<instance>` |
| `class.name` | string | Yes | The SandboxClass or ClusterSandboxClass each sandbox is built from |
| `class.kind` | string | No | `SandboxClass` (default, looked up in the claim's namespace) or `ClusterSandboxClass` |
| `maxInstances` | int32 | No | Instances of this sandbox one session may hold. Default `1`, minimum `1`. Above `1`, every tool of every handler on the sandbox gains an optional `sandbox` argument that names which one to use; see [Instances](#sandbox-instances). `AgentRuntime.spec.sandbox.maxPerSession` caps the session's total across sandboxes. Must be `1` when `targetPath` is set |
| `allocation` | string | No | `lazy` (default) claims on the first tool call of a handler on the sandbox; `eager` claims when the session starts. The runtime prepares each eager sandbox once per session, in the background and best-effort, on any facade (WebSocket, A2A, REST or MCP): a refusal or failure is logged, does not fail the session's open or delay its first message, and the first tool call of a handler on the sandbox then acquires it as a lazy one would |
| `workspace` | string | No | Where each sandbox's `/workspace` lives. `claim` (default) gives every sandbox its own scratch directory, lost with the sandbox. `session` gives each of the session's handles one directory that outlives its sandboxes: a sandbox reaped when idle, or replaced after a failure, comes back to the same files. `session` needs a class with a workspace mount. See [Session workspaces](#session-workspaces). A mount the session declares overrides it |
| `targetPath` | string | No | Project the sandboxes sync their work back into, relative to the workspace content volume (for example `projects/checkout-service`). Max 512 chars. The operator copies it onto each claim. Empty makes the sandboxes scratch space, which cannot be synced back |
| `connections` | []{role, connection} | No | The sandbox's default [Connection](/reference/core/connection/) for each role the class declares, at most 16. An AgentRuntime's [`spec.sandbox.connections`](/reference/core/agentruntime/#sandbox) overrides a role for that agent |

The CRD rejects a sandbox whose `targetPath` is set and whose `maxInstances`
is above `1`. Every instance takes the sandbox's `targetPath`, so several
instances would each promote into the same project, and the last to sync would
replace the others' work. Declare a separate sandbox, with its own
`targetPath`, for each project.

The operator reads the class, `maxInstances` and `targetPath` from the sandbox
each time it creates a claim, and resolves the Connection bindings: the
AgentRuntime's binding for each role, else the sandbox's default. It copies the
result onto the claim's `spec.connections`. The runtime's request names only
the sandbox's handle, `<sandbox>/<instance>`.

### Handlers on a sandbox

A handler's `sandbox` block names the sandbox it is served from and the class
port it dials:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `sandbox.name` | string | Yes | A sandbox in `spec.sandboxes`. The `SandboxesValid` [condition](#conditions) reports a name the registry does not declare |
| `sandbox.port` | string | Yes | The class port this handler dials. The `SandboxesValid` condition reports a port the class does not declare |

The handler has no address of its own. The runtime asks the operator's Sandbox
API for the session's sandbox, takes the address of the named port from the
[SandboxClaim](/reference/core/sandboxclaim/) it returns, and joins the
handler's endpoint path to it. `http`, `openapi`, `grpc` and `mcp` handlers can
address a sandbox. The CRD rejects a `sandbox` block on a `client` or `platform`
handler, and for the other types requires:

| Type | On a sandbox |
|------|--------------|
| `mcp` | `mcpConfig.endpoint` is a path starting with `/`. `mcpConfig.toolFilter.allowlist` is required, because the sandbox's tools are not listed until a session claims one |
| `http` | `httpConfig.endpoint`, and `urlTemplate` if set, are paths starting with `/`, joined to `http://` and the address of the named port |
| `openapi` | `openAPIConfig.baseURL`, if set, is a path starting with `/`; it defaults to the port's root, and the spec's `servers` are ignored. `specURL` is read when the runtime starts, before any sandbox exists, so it must be reachable without one |
| `grpc` | `grpcConfig.endpoint` is left unset, and the runtime dials the port's `host:port`. `tool` is required, because `ListTools` cannot be called before a sandbox exists |

For `mcp`, the allowlist is the whole of what the model is offered: the class
is a backend, and says nothing about its image's tools, so another registry
may offer different tools of the same class.

Several handlers can name one sandbox, each on its own port. They share its
claims: a session holds one sandbox pod per sandbox and instance, whichever of
its handlers is called first. Here an MCP server and an HTTP API served from
the same pod reach the same `/workspace`:

```yaml
spec:
  sandboxes:
    - name: build
      class:
        name: build-box
        kind: SandboxClass
  handlers:
    - name: build-shell
      type: mcp
      sandbox: {name: build, port: mcp}
      mcpConfig:
        transport: sse
        endpoint: /sse
        toolFilter:
          allowlist: [Read, Bash]
    - name: build-status
      type: http
      sandbox: {name: build, port: api}
      httpConfig:
        endpoint: /status
        method: GET
      tool:
        name: build_status
        description: "Report the state of the current build"
        inputSchema:
          type: object
```

### Session workspaces

With `workspace: session`, the operator keeps each handle's files for as long
as the session is kept:

- The directory is `sessions/<runtime>/<key>/handles/<sandbox>/<instance>` on
  the workspace content volume, where `<key>` is a hash of the session id. The
  operator derives it from the claim's own runtime, session and handle.
  Nothing the client, the model or the sandbox says can name another one.
- Every sandbox of that session and handle mounts it read-write, so the class
  needs only a `scratch` mount.
- The operator removes the directory once the session is gone from its
  session-api, because retention dropped it or an erasure request deleted
  it, and once no claim of the session remains. It asks at least three times
  over at least an hour before removing anything. A workspace whose service
  group runs no session-api keeps its session directories. The directories of
  a deleted AgentRuntime are removed on the same schedule.

A session workspace claim is privileged, like a project mount: only the
operator may create one, and it is honoured only while the chart's
sandbox-claim admission policy is in force (Kubernetes 1.30 or later). Where
the policy is not in force, the operator refuses the sandbox's claims
(`ClaimPolicyNotInForce`) rather than falling back to scratch, because a claim anyone could write would
reach any session's files. A class with no `filesystem.workspace` (such as the
chart's browser class) has no workspace, so a sandbox of it must keep
`workspace: claim`; the registry reports `SandboxesValid=False` with reason
`WorkspaceNotSupported` otherwise.

A session workspace is the session's own, not a project. With a `targetPath`,
sync-back promotes from it exactly as it does from a scratch directory.

### Sandbox instances

With `maxInstances: 1`, a session has one instance of the sandbox, under the
handle `<sandbox>/default`, and the tools' schemas are the pack's.

With `maxInstances` above `1`, the runtime adds one optional string property,
`sandbox`, to the schema of every tool of every handler on the sandbox. The
model names an instance with it, and each name is a separate sandbox of the
same class for the rest of the session:

- The property's description tells the model the cap: `Which sandbox to use.
  Omit for "default". Use a new name for a separate sandbox, up to <maxInstances>.
  Names are lowercase letters, digits and '-'.`
- An omitted `sandbox` means `default`.
- A name must be a DNS label: lowercase letters, digits and `-`, starting and
  ending with a letter or digit.
- The runtime removes `sandbox` from the arguments before it calls the tool,
  so the sandbox image never receives it. ToolPolicy rules and the recorded
  tool call still see it.
- A call that would open a sandbox beyond `maxInstances` fails with
  `MaxInstances`, and the error lists the names the session already holds.
- A sandbox image whose own tool already declares a `sandbox` argument is
  refused when the runtime connects to it, naming the tool.

Unused instances are freed by the idle reaper, like any other sandbox. See
[Use several sandboxes in one session](/how-to/tools/use-several-sandboxes/).

## Status fields

### `phase`

Current phase of the ToolRegistry:

| Value | Description |
|-------|-------------|
| `Pending` | Initial state before the first reconcile completes |
| `Ready` | Every discovered tool is `Available` |
| `Degraded` | Some tools `Available`, some `Unavailable` (only when [probing](#probing) is enabled) |
| `Failed` | A handler failed validation, no tools were discovered, or (with probing) no tool is reachable |

:::note[Status is config-level unless you enable probing]
By default the controller marks a tool `Available` when its handler config is
valid and the endpoint resolves — it does **not** contact the backend, so
`phase: Ready` means "the configuration is valid", not "the backend is up", and
`Degraded`/`Unavailable` are never emitted. Enable [`spec.probe`](#probing) to have
the controller TCP-probe each endpoint's reachability and reflect it in status.
:::

## Probing

By default a tool's status reflects **configuration validity** only. Set
`spec.probe.enabled: true` to have the controller periodically **TCP-dial** each
tool's resolved endpoint and mark it `Available` or `Unavailable`, which drives
the registry `phase` (`Ready` when all reachable, `Degraded` when some are not,
`Failed` when none are). It is a reachability check (a TCP connect), not a tool
invocation, so it has no side effects on the backend.

```yaml
spec:
  probe:
    enabled: true
    interval: "60s"   # how often to re-probe (default 60s)
    timeout: "5s"     # per-endpoint dial timeout (default 5s)
  handlers:
    - name: order-lookup
      # …
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | none (required within `spec.probe`) | Turn on reachability probing. Including the `probe:` stanza without `enabled` set is rejected by the API server; omitting `probe` entirely behaves as disabled. |
| `interval` | string | `60s` | How often endpoints are re-probed |
| `timeout` | string | `5s` | Per-endpoint TCP dial timeout |

Client tools (`client://browser`) have no network address and are never probed
(they stay `Available`). Probing checks that the
port accepts a TCP connection — it does not verify the tool actually works;
[Test a tool](/how-to/tools/test-tools/) exercises the real call.

### `discoveredToolsCount`

Total number of tools discovered across all handlers.

### `policyCount`

How many ToolPolicies name this registry in `spec.selector.registry`.

**Zero is the value to look for.** The policy broker is default-deny: a tool
call is permitted only by a matching `permit`, so a registry that no policy
selects refuses every call with `deniedBy=no-matching-permit` — while the
registry itself reports `Ready` with its full tool count, because it is valid
and its tools do exist. The `POLICIES` column and the `PolicyBound` condition
are what tell the two apart.

The field is **absent**, rather than `0`, on an install with no policy engine:
without the ToolPolicy CRD the question does not apply, which is different from
a registry that has no policy.

### `contentDigest`

`sha256:<hex>` over the resolved tool set — tool names, descriptions, schemas
and endpoints. It changes when what an agent can reach changes, and not when a
probe flips a tool's health.

### `lastDiscoveryTime`

When the controller last processed the handlers.

### `discoveredTools`

List of discovered tools with their status:

```yaml
status:
  discoveredTools:
    - handlerName: calculator
      name: calculate
      status: Available
      endpoint: https://api.example.com/calculate
    - handlerName: petstore
      name: petstore
      status: Available
      endpoint: https://petstore.swagger.io/v2/swagger.json
```

### `conditions`

| Type | Description |
|------|-------------|
| `Ready` | `True` (reason `ToolsDiscovered`) when handlers are valid and at least one tool is available; `False` with reason `ValidationFailed` or `NoToolsAvailable` otherwise |
| `Degraded` | `True` (reason `SomeToolsUnavailable`) when the phase is `Degraded`; `False` (reason `AllToolsAvailable`) otherwise |
| `HandlersValid` | `True` when every handler passed validation; `False` (with the errors) otherwise |
| `ToolsDiscovered` | Reports how many tools were discovered from how many handlers |
| `PolicyBound` | `True` when at least one ToolPolicy selects this registry, naming them; `False` when none does, which means the default-deny broker refuses every call. Absent when the ToolPolicy CRD is not installed |
| `SandboxesValid` | `True` (reason `SandboxesValid`) when every sandbox's class exists, and every handler on a sandbox names one in `spec.sandboxes` and a `port` its class declares. `False` with the reason of the first problem found, and a message naming each failing sandbox or handler: `ClassNotFound` or `ClassResolveFailed` for a sandbox's class, `WorkspaceNotSupported` for `workspace: session` on a class with no workspace mount, `SandboxNotDeclared` for a handler naming a sandbox the registry does not declare, `PortNotDeclared` for a port the class does not declare, `RuntimeClassNotFound` for a class whose `runtimeClassName` names no RuntimeClass, `UnsupportedOnRuntime` for a class using a feature its runtime family cannot serve (on Kata-Firecracker, `filesystem.workspace` and `filesystem.scratch`). Re-evaluated when a referenced SandboxClass, ClusterSandboxClass or RuntimeClass changes. Absent when the registry has no sandbox and no handler on one |
| `SandboxConnectionsValid` | `True` (reason `ConnectionsValid`) when every sandbox's default `connections` check against its class: each role is declared, each Connection exists, a `browserSession` Connection is bound only to a role that declares its `session` delivery, and no two place the same environment variable or file. `False` with the [check's reason](/reference/core/connection/#checks) and a message naming the sandbox. A required role left unbound is not reported here, since an AgentRuntime may still bind it. Re-evaluated when a bound Connection changes. Absent when no sandbox has default bindings |
| `HandlerConnectionsValid` | `True` (reason `HandlerConnectionsValid`) when every handler with `auth.connectionRef` can read its credential: the Connection exists and is kind `secret`, lists every host the handler sends the credential to (`https` on 443), and its Secret has the key. `False` with the [check's reason](/reference/core/connection/#checks) (`ConnectionNotFound`, `ConnectionKindNotAccepted`, `ConnectionHostNotCovered`, `ConnectionSecretNotFound`, `ConnectionSecretKeyMissing`) and a message naming the handler and, for a host, the host. Re-evaluated when the Connection or its Secret changes. Absent when no handler has `auth.connectionRef` |

## Complete example

ToolRegistry with multiple handler types:

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: ToolRegistry
metadata:
  name: agent-tools
  namespace: agents
spec:
  handlers:
    # Explicit HTTP tool with schema
    - name: calculator
      type: http
      httpConfig:
        endpoint: https://api.example.com/calculate
        method: POST
      tool:
        name: calculate
        description: "Perform mathematical calculations"
        inputSchema:
          type: object
          properties:
            expression:
              type: string
              description: "Mathematical expression to evaluate"
          required: [expression]
      timeout: "10s"

    # gRPC tool service
    - name: user-service
      type: grpc
      grpcConfig:
        endpoint: user-grpc.internal.svc.cluster.local:50051
      tool:
        name: get_user
        description: "Retrieve user information"
        inputSchema:
          type: object
          properties:
            user_id:
              type: string
          required: [user_id]

    # Self-describing MCP server
    - name: code-tools
      type: mcp
      mcpConfig:
        transport: sse
        endpoint: http://mcp-code.tools.svc.cluster.local:8080/sse

    # Self-describing OpenAPI service
    - name: external-api
      type: openapi
      openAPIConfig:
        specURL: https://api.example.com/openapi.json
        operationFilter:
          - searchProducts
          - getProductDetails
```

Status after reconcile:

```yaml
status:
  phase: Ready
  discoveredToolsCount: 4
  policyCount: 1
  discoveredTools:
    - handlerName: calculator
      name: calculate
      status: Available
    - handlerName: user-service
      name: get_user
      status: Available
    - handlerName: code-tools
      name: code-tools
      status: Available
    - handlerName: external-api
      name: external-api
      status: Available
  conditions:
    - type: HandlersValid
      status: "True"
    - type: ToolsDiscovered
      status: "True"
    - type: PolicyBound
      status: "True"
      reason: PolicySelectsRegistry
      message: "selected by 1 ToolPolicy(s): [calculator-policy]"
```

:::note[Self-describing handlers report one placeholder entry until runtime]
`mcp` and `openapi` handlers contribute a single placeholder `discoveredTools`
entry (named after the handler) at reconcile time. The individual tools they
expose are discovered by the runtime when the agent starts.
:::