---
title: Ennodia IO
description: The local interface for app-facing artificial intelligence (AI) provider options and chat calls.
---

Ennodia IO is a local Hypertext Transfer Protocol (HTTP) server for artificial
intelligence (AI) provider options. It uses the same Ennodia Core as the Model
Context Protocol (MCP) tools. It supports a bring your own key (BYOK) setup
without requiring a provider application programming interface (API) key.
The app can show runnable local agents in its user interface (UI). It can then
send chat-style requests to one option.

It is not a hosted model router. Ennodia does not sell model access, proxy
provider billing, or hide which local agents ran. IO ships as the separate
`@cherninlab/ennodia-io` package.

When Compare is enabled, Ennodia keeps the roles explicit. The Judge evaluates
candidate results. The Result Advisor combines the Judge analysis and candidate
evidence into the final answer.

## Start IO

```sh
npx -y @cherninlab/ennodia-io
```

From a checkout:

```sh
bun run --cwd packages/ennodia-io start
```

By default IO listens on `127.0.0.1:17273`. Non-loopback binding requires an API
key:

```sh
ENNODIA_IO_API_KEY="local-secret" npx -y @cherninlab/ennodia-io --host 0.0.0.0
```

Flags:

| Flag | Default | Meaning |
| --- | --- | --- |
| `--host` | `127.0.0.1` | HTTP bind host. |
| `--port` | `17273` | HTTP bind port. |
| `--api-key` | `ENNODIA_IO_API_KEY` | Require `Authorization: Bearer <key>`. |
| `--max-request-body-size` | `2097152` | Maximum request body bytes before a structured `413`. |
| `--max-concurrent-chat-completions` | `4` | Maximum in-flight chat completions before a structured `429`. |

Environment variables:

| Variable | Meaning |
| --- | --- |
| `ENNODIA_IO_HOST` | Default host when `--host` is omitted. |
| `ENNODIA_IO_PORT` | Default port when `--port` is omitted. |
| `ENNODIA_IO_API_KEY` | API key required by the HTTP handler. |
| `ENNODIA_IO_MAX_REQUEST_BODY_SIZE` | Default request body byte cap. |
| `ENNODIA_IO_MAX_CONCURRENT_CHAT_COMPLETIONS` | Default in-flight chat completion cap. |

## Security and browser access

IO binds to loopback by default and refuses non-loopback hosts unless an API key
is configured. Bearer tokens are compared with a timing-safe comparison.

IO checks the `Host` header on every route. In the default loopback posture,
the host must be `localhost`, `::1`, or an address in `127.0.0.0/8`. A forged
`Host` header, such as one from Domain Name System (DNS) rebinding, gets a
`403 invalid_host` error. An explicit non-loopback `--host` turns off this
allowlist and requires the API key.

IO checks the `Origin` header when a request carries one. Only loopback origins
are accepted. A browser post from a non-loopback origin gets a `403 invalid_origin`
error before any run starts. Clients that send no `Origin` header, such as
local command-line tools, keep working.

`POST /v1/chat/completions` requires a JSON media type such as
`application/json`. Other media types get a `415 unsupported_media_type` error.
This requirement stops simple-request browser posts, for example cross-site
forms that send `text/plain`.

IO intentionally sends no Cross-Origin Resource Sharing (CORS) headers by
default. Browsers cannot read cross-origin responses or send cross-origin JSON posts
unless their app uses an appropriate same-origin proxy.
There is no `--cors-origin` flag. Add one only when an app needs that trust
boundary.

## Routes

| Route | Status | Notes |
| --- | --- | --- |
| `GET /health` | Supported | Returns server name, status, and Ennodia version. |
| `GET /v1/provider-options` | Supported | Returns app-facing local provider options for settings screens. |
| `GET /v1/byok-options` | Supported alias | Same response as provider options. |
| `GET /v1/models` | Supported | Returns local virtual model IDs for compatible clients. |
| `POST /v1/chat/completions` | Supported subset | Non-streaming text messages only. Requires a JSON media type. |

## Provider options

Use provider options when building a settings screen:

```sh
curl http://127.0.0.1:17273/v1/provider-options
```

The response is intentionally app-facing:

```json
{
  "object": "list",
  "defaultModel": "local/auto",
  "compareModel": "local/compare",
  "options": [
    {
      "id": "codex",
      "label": "Codex CLI",
      "kind": "local-agent-cli",
      "status": "ready",
      "available": true,
      "runnable": true,
      "configured": true,
      "model": "local/codex",
      "version": "codex-cli 0.141.0"
    }
  ]
}
```

Option statuses:

| Status | Meaning |
| --- | --- |
| `ready` | The local agent is runnable now. |
| `installed` | An app or partial install was detected, but Ennodia cannot run it through a supported command-line interface (CLI) surface yet. |
| `missing` | The supported local agent was not found. |

Add `?includeUnavailable=false` to return only ready options.

## Virtual models

`GET /v1/models` returns local virtual models for OpenAI-compatible clients:

| Model | Meaning |
| --- | --- |
| `local/auto` | Let Ennodia choose the local harness. |
| `local/compare` | Run parallel local harnesses, evaluate them with the Judge, and combine the result with the Result Advisor. |
| `local/<harness-id>` | Force a specific harness, such as `local/codex` or `local/claude-code`. |

Older aliases `ennodia-auto`, `ennodia/auto`, and `ennodia/compare` are accepted
for compatibility. Prefer `local/*` IDs in new app integrations.

## Chat completions

Minimal request:

```sh
curl http://127.0.0.1:17273/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{
    "model": "local/auto",
    "messages": [
      { "role": "user", "content": "Review this plan before I ship it." }
    ]
  }'
```

With Ennodia options:

```json
{
  "model": "local/codex",
  "messages": [
    { "role": "system", "content": "Be concise." },
    { "role": "user", "content": "Find the highest-risk issue." }
  ],
  "ennodia": {
    "category": "code",
    "harnessId": "codex",
    "mode": "single",
    "compare": false,
    "timeoutMs": 300000
  }
}
```

Supported top-level fields:

| Field | Meaning |
| --- | --- |
| `model` | Required OpenAI-compatible routing label. Use `local/auto`, `local/compare`, or a provider option's `model`. |
| `messages` | Required array of text-only `system`, `user`, `assistant`, or `tool` messages. |
| `stream` | Optional. Must be `false` or omitted. |
| `temperature`, `top_p`, `max_tokens`, `user` | Accepted for client compatibility but not used for local agent routing. |
| `ennodia` | Optional Ennodia-specific routing and timeout options. |

Supported `ennodia` fields:

| Field | Meaning |
| --- | --- |
| `category` | Optional caller-provided route category: `code`, `research`, `browser`, `image`, or `general`. |
| `harnessId` | Force one harness by adapter ID, such as `codex` or `claude-code`. |
| `mode` | `auto`, `single`, or `parallel`. |
| `compare` | `auto`, `true`, or `false`. |
| `cwd` | Working directory for child harness commands. |
| `model` | Child harness model override. Top-level `model` is only the HTTP routing label. |
| `timeoutMs` | Per-child timeout, capped at one hour. |
| `refresh` | Re-scan harnesses before planning. |
| `judgeHarnessId`, `judgeModel` | Compare judge overrides. |
| `advisorHarnessId`, `advisorModel` | Compare Result Advisor overrides. |
| `synthesizerHarnessId`, `synthesizerModel` | Deprecated aliases for `advisorHarnessId` and `advisorModel`. If both forms are supplied, their values must match. |
| `maxOutputChars` | Candidate output cap for Compare. |
| `maxWaitMs` | HTTP request wait cap, capped at one hour. |

Chat completions are non-streaming. A request waits until the Ennodia run
finishes or `maxWaitMs` elapses. The default is 10 minutes. Disable streaming in
OpenAI-compatible clients and set `ennodia.maxWaitMs` when the client or proxy
needs a shorter or longer hold time.

The response includes the normal OpenAI-style `choices[0].message.content` plus
an `ennodia` object. The object contains the run ID, status, selected harnesses,
task IDs, Compare ID when present, and budget estimate.

Handler errors use JSON with `cache-control: no-store`. The native server can
reject a request before the handler runs, so clients must also handle an empty
HTTP error response:

- disallowed `Host` header or `Origin` header -> `403 invalid_host` or `403 invalid_origin`
- non-JSON media type on chat completions -> `415 unsupported_media_type`
- request body above the configured cap -> HTTP `413`. Bun can reject it with
  an empty body before the handler runs. Requests rejected by the handler use
  the JSON error `request_too_large`.
- saturated chat completions -> `429 rate_limit_error` with `retry-after: 1`
- client abort after the run starts -> `499 client_closed_request`. IO cancels
  the Ennodia run when Core exposes cancellation
- timeout, disappeared run, or start failure -> `502 ennodia_run_error`
- failed run or run with `cancelled` status -> `502 ennodia_run_failed` plus run metadata

## Library use

Apps can import the same primitives without running the HTTP server:

```ts
import { createDefaultEnnodiaCore } from "ennodia";
import { listAppProviderOptions } from "@cherninlab/ennodia-io";

const core = createDefaultEnnodiaCore();
const providerOptions = await listAppProviderOptions(core);
```

The app stays in control of its settings UI, labels, persistence, and consent
flow. Ennodia supplies local discovery and execution primitives.

## Smoke test

From a checkout:

```sh
bun run io:smoke
```

The smoke test starts a local IO server and checks `/health`,
`/v1/provider-options`, and `/v1/models` without launching child agents.

## Not implemented

IO deliberately rejects or omits:

- streaming responses. Child CLIs return text at task boundaries, not token
  deltas, so clients must set `stream: false` or omit `stream`.
- tool calls and function calls
- multimodal content arrays, image input, audio input, and file uploads
- embeddings, responses, assistants, batches, and fine-tuning routes
- hosted model fallback, provider pricing controls, or provider billing
- IO-specific history endpoints. Terminal run history is written by Core when
  history is enabled, but IO does not expose a read API for it.

Use MCP to inspect detailed run, task, Judge, Result Advisor, and Compare state
while work is in progress. Use IO when a local app needs a small HTTP bridge.
