Skip to content

Ennodia IO

Ennodia IO is a local HTTP server over the same Ennodia Core used by the MCP tools. It is useful when an app wants BYOK-style settings without asking the user for a provider API key first. The app can ask Ennodia what local agents are installed and runnable, show those options in its own settings UI, then send chat-style requests to the selected local 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.

Terminal window
npx -y @cherninlab/ennodia-io

From a checkout:

Terminal window
bun run --cwd packages/ennodia-io start

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

Terminal window
ENNODIA_IO_API_KEY="local-secret" npx -y @cherninlab/ennodia-io --host 0.0.0.0

Flags:

FlagDefaultMeaning
--host127.0.0.1HTTP bind host.
--port17273HTTP bind port.
--api-keyENNODIA_IO_API_KEYRequire Authorization: Bearer <key>.
--max-request-body-size2097152Maximum request body bytes before a structured 413.
--max-concurrent-chat-completions4Maximum in-flight chat completions before a structured 429.

Environment variables:

VariableMeaning
ENNODIA_IO_HOSTDefault host when --host is omitted.
ENNODIA_IO_PORTDefault port when --port is omitted.
ENNODIA_IO_API_KEYAPI key required by the HTTP handler.
ENNODIA_IO_MAX_REQUEST_BODY_SIZEDefault request body byte cap.
ENNODIA_IO_MAX_CONCURRENT_CHAT_COMPLETIONSDefault in-flight chat completion cap.

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 intentionally sends no CORS headers by default. Browser-based local apps are therefore blocked by the browser unless they proxy through their own local origin. There is no --cors-origin flag yet; add one only when a real app needs that trust boundary.

RouteStatusNotes
GET /healthSupportedReturns server name, status, and Ennodia version.
GET /v1/provider-optionsSupportedReturns app-facing local provider options for settings screens.
GET /v1/byok-optionsSupported aliasSame response as provider options.
GET /v1/modelsSupportedReturns local virtual model IDs for compatible clients.
POST /v1/chat/completionsSupported subsetNon-streaming text messages only.

Use provider options when building a settings screen:

Terminal window
curl http://127.0.0.1:17273/v1/provider-options

The response is intentionally app-facing:

{
"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:

StatusMeaning
readyThe local agent is runnable now.
installedAn app or partial install was detected, but Ennodia cannot run it through a supported CLI surface yet.
missingThe supported local agent was not found.

Add ?includeUnavailable=false to return only ready options.

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

ModelMeaning
local/autoLet Ennodia choose the local harness.
local/compareRun parallel local harnesses and synthesize with Compare.
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, but new app integrations should prefer local/* IDs.

Minimal request:

Terminal window
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:

{
"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:

FieldMeaning
modelRequired OpenAI-compatible routing label. Use local/auto, local/compare, or a provider option’s model.
messagesRequired array of text-only system, user, assistant, or tool messages.
streamOptional. Must be false or omitted.
temperature, top_p, max_tokens, userAccepted for client compatibility but not used for local agent routing.
ennodiaOptional Ennodia-specific routing and timeout options.

Supported ennodia fields:

FieldMeaning
categoryOptional caller-provided route category: code, research, browser, image, or general.
harnessIdForce one harness by adapter ID, such as codex or claude-code.
modeauto, single, or parallel.
compareauto, true, or false.
cwdWorking directory for child harness commands.
modelChild harness model override. Top-level model is only the HTTP routing label.
timeoutMsPer-child timeout, capped at one hour.
refreshRe-scan harnesses before planning.
judgeHarnessId, judgeModelCompare judge overrides.
synthesizerHarnessId, synthesizerModelCompare synthesizer overrides.
maxOutputCharsCandidate output cap for Compare.
maxWaitMsHTTP 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 with the run ID, status, selected harnesses, task IDs, Compare ID when present, and budget estimate.

Limits and failures are returned as JSON with cache-control: no-store:

  • request body above the configured cap -> 413 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 or cancelled terminal run -> 502 ennodia_run_failed plus run metadata

Apps can import the same primitives instead of running the HTTP server:

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.

From a checkout:

Terminal window
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.

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 when an agent should inspect detailed run, task, and Compare state while work is still in progress. Use IO when a local app needs a small HTTP bridge.