TangleBrain exposes four contract surfaces. All four are consumed from outside this repo — the package is on PyPI — so none of them can be changed in lockstep with its consumers.
Mechanism lives in ARCHITECTURE.md. This document records what is
promised, what is not, and what breaks whom.
These bind. Departing from one is a decision to record and justify.
-
An unknown routing directive errors; it never falls back. An unrecognized
modelon the HTTP surface is404 model_not_found; an unknown delegatetargetdoes not silently become something else.Why: a silent fallback spends a different backend's quota while returning what looks like success. The caller gets a plausible answer from a backend they did not choose, at a cost they did not authorize, with nothing anywhere recording that a substitution happened. An error is recoverable; a wrong-but-confident answer is not.
-
A "no fit" outcome is a signal, not an error.
NoDelegateFitsurfaces to the orchestrator as an instruction to handle the sub-task itself; a failing item indelegate_manygets a per-itemstatusand never sinks the batch.Why: the consumer is a model that cannot ask a clarifying question. Raising at it converts a routable situation into a dead end, when the honest information — "no configured backend fits this, do it yourself" — is something it can act on immediately. Distinguishing the two is what keeps a partial failure partial.
-
A roster
idis public API in two namespaces at once. It is themodelvalue on the HTTP surface and thetargetvalue on the MCP surface.Why: renaming one breaks both, and neither consumer can be migrated in lockstep — one is an arbitrary OpenAI client, the other is a model reading a tool description. Treating ids as internal labels is the easiest way to ship a breaking change without noticing you did.
-
New fields are additive and optional across every surface. Canonical statement and rationale live in
data-model.md.
The widest surface: any OpenAI client is a potential consumer, and it will never read TangleBrain's documentation.
Endpoints
POST /v1/chat/completionsGET /v1/models— theautoalias plus every roster id.
The model field is a routing directive, which is the one place TangleBrain overloads OpenAI
semantics:
auto→ the full router (classifier gate honored per settings)- a roster id → pins that entry
- anything else →
404with codemodel_not_found. Never a fallback.
Requests. Messages are flattened into a role-tagged transcript. Non-text parts (images, audio) are rejected explicitly rather than silently dropped — dropping them would answer a different question than the one asked.
Streaming (stream: true) is genuinely incremental where the backend supports it
(openai-compat / api kinds via the adapters' optional run_stream). Backends that cannot stream
(cli kinds) deliver the completed text as one chunk — a valid stream, not an error. The first
delta is pulled before headers commit, so a connect-time failure is a plain JSON error rather
than a half-open stream.
Errors — OpenAI-shaped {"error": {"message", "type", "code"}}, deliberately not RFC-7807,
because compatibility includes error handling:
| Status | Type | When |
|---|---|---|
| 400 | invalid_request_error |
malformed payload, non-text message part |
| 404 | invalid_request_error + code model_not_found |
unknown model |
| 502 | upstream_error |
AdapterError / RouterError; stream ended before content |
| — | server_error |
nonconforming adapter or internal bug |
Mid-stream failure contract: one {"error": ...} SSE event and no [DONE]. A client can
always distinguish failure from clean close.
Authentication: none, by design. The Authorization header is never read. The loopback bind is
the entire access control model — see security-model.md.
Extension: X-TangleBrain-Parent-Task is sanitized and recorded as parent_task_id. Metadata
only — never routed on. That restraint is the contract: a header cannot influence backend
selection or spend.
Consumed by an orchestrator model, which is the hardest consumer to serve: it reads tool descriptions rather than docs, cannot ask a clarifying question, and cannot be migrated when a signature changes.
| Tool | Signature | Contract |
|---|---|---|
delegate_local |
(prompt, max_tokens?) |
Free local tier. The $0 default. |
delegate |
(prompt, target?, task?, max_tokens?) |
Precedence target > task > local. |
delegate_many |
(tasks, max_concurrency?) |
Concurrent fan-out, per-item routing. |
delegate_targets |
() |
The configured menu: id, tier, good_at, cost, kind. |
Selection rules for delegate:
target— an explicit roster id flaggedcan_delegate: true.task— a capability tag; picks the cheapestcan_delegateentry whosegood_atcontains it (TIER_RANKlocal<sub, ties by declared order).apiis never auto-selected bytask. Paid is a last resort, never a preference. A paid target must be named explicitly and still passes the billing gate.- No fit →
NoDelegateFit, which the tool converts into an instruction for the orchestrator to handle the sub-task itself. - The target is built as a leaf (
inject_delegate=False) — delegation never recurses.
Capability routing currently ranks by cost only, so it can route down but never up: there is no way to say "this sub-task is hard, send it somewhere better" without naming an id. Tracked in #97.
delegate_many guarantees: results in input order, each with status
(ok / no_fit / error); a failing item never sinks the batch; concurrency bounded by
_effective_concurrency, where a per-call value may lower but never raise the operator's bound.
Dispatch and collect only — synthesis is deliberately the orchestrator's, and there is no
reducer tool by design.
The delegate tool description enumerates the target menu and is built once at server startup —
so a roster edit is not visible to an already-running server. Restart is required. Recorded because
a model reading a stale menu will confidently route to a target that no longer exists.
| Flag | Contract |
|---|---|
prompt (positional) |
Optional only with --stats. |
--version |
Prints version, exits. |
--roster PATH |
Explicit roster path. |
--model ID |
Pin a roster entry. Explicit override of routing. |
--local |
Force the free local tier. |
--task TAG |
Task-fit hint (a good_at tag). |
--gate / --no-gate |
Force the classifier gate on/off for this run. |
--max-tokens N |
Override the completion cap (adapter default 2048). |
--stats |
Print the spend-avoided rollup and exit. |
--route |
Deprecated no-op, kept for back-compat. |
--route is the project's de facto deprecation policy, demonstrated: a superseded flag is kept
as an accepted no-op rather than removed, so an existing script keeps working. Worth promoting from
precedent to stated policy — it is currently a pattern one flag deep.
Two known defects on this surface:
--helpmisstates the--rosterdefault — #102.--modelon an orchestrator-capable entry silently strips its delegate tool — #96.
Localhost-only, internal. Views the roster, pricing, and rollup; runs a prompt; edits pricing plus a
focused subset of per-entry roster fields (enabled, can_orchestrate, budget_usd_month,
good_at).
That the editable set is a fixed allow-list is the mass-assignment control — the panel cannot be
talked into writing invoke, key_ref, or tier. Writes are validated, atomic, backed up with a
timestamp, and comment-preserving.
Secrets are never resolved or sent to the browser — a key_ref renders as its reference string.
Assessed against the OWASP API risk categories, because these surfaces carry credentials and spend money.
- Broken object-level authorization (BOLA) — not applicable. No objects, no users, no per-object ownership. Single operator.
- Broken authentication — accepted, by design. There is none; the loopback bind substitutes. Sound only while the bind holds, which is why off-loopback exposure is recorded as prohibited rather than discouraged.
- Mass assignment — mitigated by the GUI's fixed editable allow-list.
- Excessive data exposure — mitigated structurally.
key_refis never resolved outward; prompt and response text is never persisted, so no endpoint can leak it. - Lack of rate limiting — accepted. A local caller can exhaust backend quota. The mitigation is that only local callers exist.
- Security misconfiguration — the live risk. The entire posture depends on two settings staying false and two servers staying on loopback.
Versioned by package semver plus CHANGELOG.md; breaking changes to the MCP
or CLI surfaces are major bumps. The HTTP /v1 path is OpenAI's, not TangleBrain's — its
compatibility is defined upstream.
No formal deprecation window exists. Practice is additive-by-default plus --route-style no-op
retention. The gap becomes live at #90
(mcp 2.x migration), where an mcp >= 2 floor would strand 1.x users — the first real test of a
policy that has not been written down.