The six first-class, Colleague-facing roles lobes exposes over the fleet —
cortex/senses/embedder/reranker/stt/tts— how a caller discovers them, drives them, measures them, and the before→after context migration that shipped alongside this contract (issue #81).
This doc is the role contract reference. For the fleet's Docker topology,
tuning knobs, and memory budget, see docs/gateway-fleet.md;
for the raw OpenAI wire endpoints (/v1/chat/completions, /v1/embeddings, …)
see docs/openai-api.md.
Before issue #81, a Colleague client that wanted the vision-capable gear had to
know its literal served name (coolthor/gemma-4-12B-it-NVFP4A16) and hardcode
it. That breaks the moment an operator swaps the checkpoint. The role
vocabulary fixes this: a client asks for a capability (cortex — reasoning
and decisions; senses — perception and intake) and lobes resolves it to
whichever concrete model, endpoint, and context that capability currently
serves. Renaming or re-quantizing the underlying checkpoint is then an
operator-side change with zero client-code change — see "Client flow"
below.
| Role | Backend / service | Endpoint path | What it's for |
|---|---|---|---|
cortex |
primary (generate) |
POST /v1/chat/completions |
Reasoning, deciding, planning, tool use, repo actions — the final authority. |
senses |
multimodal (generate) |
POST /v1/chat/completions |
Intake/perception (text+image) and speaking back to the user. Does not decide or act. |
embedder |
embed (pooling) |
POST /v1/embeddings |
Dense text embeddings for memory/retrieval. |
reranker |
rerank (pooling) |
POST /v1/rerank (+ /v1/score) |
Reordering/scoring retrieved candidates. |
stt |
Parakeet (audio overlay, opt-in) | POST /v1/audio/transcriptions |
Speech-to-text. |
tts |
Chatterbox (audio overlay, opt-in) | POST /v1/audio/speech |
Text-to-speech. |
sensesis vision-only intake — audio is not currently served (issue #101). Thecoolthor/gemma-4-12B-it-NVFP4A16checkpoint behindsensesdeclares anaudio_configin its own model config, but on this vLLM serving path (gemma4_unified) aninput_audiocontent part is silently dropped rather than rejected: a caller gets200 OKand a fluent answer that ignored the audio. Live evidence and the tracking issue are indocs/gemma-4-12b-nvfp4.md. For speech, use the purpose-builtsttrole (Parakeet,POST /v1/audio/transcriptions) instead — it remains first-class and is unaffected by this gap.
cortex, senses, embedder, and reranker are always enumerated (present
with loaded=false if their gear isn't wired in this deployment); stt/tts
require lobes init --fleet --audio. brain is not a valid role name —
cortex is the only reasoning/decision role.
Each role carries a declared division of labour — what it is expected to own,
and (for senses) what it must not do. These are runtime-descriptor
tokens, not correctness claims — lobes does not grade whether a role did its
job well; that judgment is Colleague's (see "Runtime-only, always" below).
| Role | responsibilities |
forbidden_responsibilities |
|---|---|---|
cortex |
reasoning, deciding, planning, tool_use, code_repo_actions, validation, final_authority |
(none — cortex is the final authority) |
senses |
intake, normalize_input, classify_intent, prepare_context_packet, speak_back |
final_decision, repo_action, security_decision |
embedder |
vectorization, memory_retrieval_input |
(none) |
reranker |
retrieval_ordering, relevance_refinement |
(none) |
stt |
transcribe, audio_input_to_text |
(none) |
tts |
speech_output, synthesize |
(none) |
cortex and senses are new names layered on the existing primary /
multimodal backends and the main / multimodal capability tiers — no
internal service, container, or env var was renamed. All three vocabularies
resolve to the same backend (lobes/catalog.py's TIER_ROLE):
Backend (role_hint) |
Primary tier alias | Back-compat tier alias | Colleague role name |
|---|---|---|---|
primary |
main |
hard |
cortex |
multimodal |
multimodal |
normal |
senses |
minor |
minor |
cheap |
(no role name — minor has no Colleague role; it's the servable floor under pressure, not a first-class capability) |
A caller can send model=cortex, model=main, or model=hard to
/v1/chat/completions and reach the exact same warm backend. All the old
aliases keep working — this is additive vocabulary, not a rename. See
docs/gateway-fleet.md for the
full tier-alias fallback contract (busy backpressure under pressure,
multimodal falling back to main when unwired, etc.) — that mechanism is
unchanged by the role layer.
A client that wants to drive any role needs exactly one thing: the fleet's base URL. Everything else — which model backs a role, whether it's loaded, what context it's served at — comes from the contract itself.
lobes capabilities # human-readable table, all six roles
lobes capabilities --json # the machine-readable contract
lobes endpoint cortex # just the base URL for one role
curl -s http://localhost:8000/capabilities # the same contract, over HTTPlobes capabilities and GET /capabilities are the same payload — both
are built by the one canonical registry builder,
lobes.roles.build_role_registry (lobes/roles.py), so there is exactly one
source of truth for the role→endpoint contract. The CLI reads the deployment's
.env off disk (soft-resolved — an unscaffolded deployment still answers,
with every role but cortex reported loaded=false); the gateway reads its
own container environment.
GET /capabilities (and lobes capabilities --json) returns an object keyed
by role name, each value carrying exactly these fields:
{
"<role>": {
"role": str, # "cortex" | "senses" | "embedder" | "reranker" | "stt" | "tts"
"model": str, # the served model id this role resolves to (never blank)
"runtime": str, # "vllm" | "parakeet" | "chatterbox"
"endpoint": str, # client-reachable base URL to dial ("" when not wired)
"path": str, # the OpenAI path, e.g. "/v1/chat/completions"
"context": int, # SERVED context in tokens (deployment override, else catalog native)
"quant": str, # vLLM quantization; "" for pooling/audio roles
"mtp": bool, # speculative decoding (MTP draft head) active
"responsibilities": [str, ...],
"forbidden_responsibilities": [str, ...],
"ready": bool | null, # see the note below
"loaded": bool # is this role's backend wired in THIS deployment?
},
...
}
Every role's endpoint is the one client-reachable gateway origin — dial
it directly (issue #87). All six roles (cortex/senses/embedder/reranker
and stt/tts) report the same base URL because routing happens via the
model field / the OpenAI path, not distinct per-role URLs; the internal
upstream hosts (vllm-primary:8000, realtime:8080) are never leaked. When you
fetch GET /capabilities, the gateway advertises the origin you actually
dialed (from the request Host header), so endpoint is reachable as-is; set
GATEWAY_PUBLIC_URL to override it for a tunnel / Host-rewriting reverse proxy.
A role is "" only when it isn't wired (e.g. stt/tts without --audio).
ready differs by transport, deliberately. lobes capabilities --json
reports a configured signal (ready == loaded) — a read-only CLI on the
host can't reach the internal backends to probe them, so it doesn't try; use
lobes measure for a CLI-side live probe. GET /capabilities is the honest
one for consumers: for stt/tts it now reports a live readiness probe of
the audio backend (issue #89) — ready: true only when an audio round-trip
would actually succeed (Chatterbox + Parakeet both up, no poisoned CUDA
context), false while they warm — so an advertised-ready audio role is truly
consumable. The four gateway-fronted roles still report ready as a
same-cost-as-loaded boolean. No ready value is a task-quality claim.
Example (cortex, fully wired, default fleet):
{
"role": "cortex",
"model": "sakamakismile/Qwen3.6-27B-Text-NVFP4-MTP",
"runtime": "vllm",
"endpoint": "http://localhost:8000",
"path": "/v1/chat/completions",
"context": 131072,
"quant": "modelopt",
"mtp": true,
"responsibilities": ["reasoning", "deciding", "planning", "tool_use", "code_repo_actions", "validation", "final_authority"],
"forbidden_responsibilities": [],
"ready": true,
"loaded": true
}An unwired role (e.g. stt/tts without --audio, or senses before the
multimodal gear is up) is never omitted — it's returned with
loaded: false and the model it would serve named from the catalog, so a
client can always render all six roles.
lobes up starts (or, with --down, stops) one role's gear without
touching the rest of the fleet:
lobes up cortex --apply # docker compose up -d vllm-primary
lobes up senses --apply # docker compose up -d vllm-multimodal
lobes up embedder --apply # docker compose up -d vllm-embed
lobes up reranker --apply # docker compose up -d vllm-rerank
lobes up stt --apply # requires the --audio overlay
lobes up tts --apply # requires the --audio overlay
lobes up colleague-stack --apply # all six roles at once (requires --audio scaffolded)Dry-run by default (prints the exact docker compose … command); --apply
commits. colleague-stack is a first-class bundle — the four default fleet
roles plus the audio overlay's stt/tts — not a compose profiles:
tag, because tagging the already-default-on services with a profile would
demote them out of the default fleet (a regression). If the audio overlay
isn't scaffolded, colleague-stack (and up stt/up tts) fail with a
remediation pointing at lobes init --fleet --audio --apply, rather than
silently starting only four of the six roles.
Read-only, per-role runtime metrics — never a task-quality or correctness claim (lobes measures serving performance; whether an answer was good is Colleague's call):
lobes measure # all six roles, table
lobes measure --json # all six roles, JSON
lobes measure --role cortex --jsonMetrics are grouped by the role's family:
- LLM roles (
cortex,senses):ttft_ms,decode_tps,prefill_tps,context,mem_usage_pct(when the vLLM/metricsscrape is cheaply reachable);restart_count/error_countare alwaysnull(not cheaply available without a docker inspect, which this verb deliberately never does). - Pooling roles (
embedder,reranker):reqs_per_sec,docs_per_sec,latency_ms,batch_size,loaded. - Audio roles (
stt,tts):rtf(real-time factor),latency_ms,duration_ms,failure_rate.
An unloaded or unreachable role degrades to ready: false with every metric
null — this is the normal case in CI or on a partial deployment, not an
error; a dead senses backend never stops cortex from being measured.
lobes benchmark --profile <name> runs a RUNTIME-ONLY, side-by-side
comparison across a fleet profile — built on the same per-role probes
lobes measure uses, not the load-test engine --all-lobes uses:
| Profile | What it compares |
|---|---|
cortex-only |
The cortex generate lane alone. |
cortex+senses |
cortex and senses side by side (the always-on duo). |
senses-direct |
senses addressed directly (cheap/front-door tasks). |
qwen-nvfp4-vs-bf16 |
The current cortex endpoint probed as both a quantized and an unquantized catalog variant — reported available: false with a reason when the catalog doesn't carry both sides (never fabricated). |
all |
Every profile above. |
lobes benchmark --profile cortex+senses --json
lobes benchmark --profile allDegrades gracefully offline: an unreachable role or a catalog-missing variant
is reported unavailable with a reason, never a crash or an invented number.
A Colleague client needs only the fleet's base URL. The whole discovery and dispatch flow:
GET <base_url>/capabilitiesonce.- Read the role you want (
cortex,senses,embedder,reranker,stt,tts) out of the response — itsendpoint,model, andpath. POST <endpoint><path>with"model": <model>and the role-appropriate body shape (chat messages forcortex/senses,inputforembedder,query+documentsforreranker).
No model id is ever hardcoded in the client. Concretely (Python, stdlib-only):
import json
import urllib.request
def call_role(base_url: str, role: str, body_extra: dict) -> dict:
with urllib.request.urlopen(base_url.rstrip("/") + "/capabilities") as r:
contract = json.load(r)
info = contract[role]
body = {"model": info["model"], **body_extra}
req = urllib.request.Request(
info["endpoint"].rstrip("/") + info["path"],
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as r:
return json.load(r)
call_role("http://localhost:8000", "cortex",
{"messages": [{"role": "user", "content": "ping"}]})Because cortex and senses are gateway-fronted, they (along with
embedder/reranker) share the same endpoint — the gateway's base
URL — and routing between them happens purely via the model field the
contract handed back. stt/tts resolve to the audio-overlay bridge URL
instead.
Rename-safety, proven. If an operator swaps cortex's served checkpoint
(PRIMARY_SERVED_NAME in the fleet .env) and re-runs lobes fleet up --apply, the very next GET /capabilities call reflects the new model id —
the client above needs zero code changes to keep working. This is proven
end-to-end in tests/test_colleague_contract.py
(test_colleague_follows_an_operator_rename_with_no_client_code_change).
Every field lobes emits about a role — in /capabilities, lobes measure, or
lobes benchmark --profile — is a serving/runtime descriptor: latency,
throughput, context, quant, load state, or a declared (not graded)
responsibility. Nothing lobes emits asserts answer correctness, task
quality, or agent-task success — judging whether a response was actually
good is Colleague's job. This boundary is enforced by test
(tests/test_colleague_contract.py::test_capabilities_contract_is_runtime_descriptor_only
and ::test_measure_registry_emits_only_allowed_runtime_metric_keys), which
scans every emitted key for quality-flavoured tokens (accuracy, correct,
quality, task_success, success_rate, grade, score) and fails if one
appears.
The Colleague-role contract shipped alongside a context rebalance. Both
cortex and senses serve less context than the legacy single-model
scaffold did solo, in exchange for co-residency:
| Deployment shape | cortex / primary served context |
senses / multimodal served context |
|---|---|---|
Legacy single-model scaffold (lobes init / lobes serve, no fleet) |
256K (VLLM_MAX_MODEL_LEN=262144, solo, util 0.6) |
(not served — single model only) |
| Fleet duo, pre-rebalance | 64K, util 0.30 | 128K (MULTIMODAL_MAX_MODEL_LEN=131072, util 0.22) |
| Fleet duo, current (this doc) | 128K (PRIMARY_MAX_MODEL_LEN=131072, util 0.30 — util-bound, not context-bound) |
32K (MULTIMODAL_MAX_MODEL_LEN=32768, util 0.14) |
The pre-rebalance duo gave the vision gear its full native 128K at the
cost of trimming cortex to 64K; the current default flips that trade-off —
cortex (the final-authority reasoning role) now gets its full native 128K,
and senses (intake/perception) is trimmed to 32K, which is ample for the
"normalize input, classify intent, prepare a context packet" responsibilities
it's actually scoped to. Running cortex solo (no fleet, no senses)
still restores the legacy single-model 256K/util-0.6 footprint — the role
contract doesn't change what a solo deployment can serve, only what the
default duo budgets. See
docs/gateway-fleet.md for the full budget table
and live-validation history behind this rebalance.
docs/gateway-fleet.md— Docker topology, tier-alias fallback contract, pressure busy-backpressure policy, memory budget, live validation history.docs/openai-api.md— the raw OpenAI-compatible wire endpoints each role sits behind.lobes explain roles— the in-CLI version of this doc.lobes explain fleet/lobes explain gateway— routing semantics.tests/test_colleague_contract.py— the end-to-end proof of the client flow and the runtime-only boundary described above.