Thinking Modes Plugin
The Thinking Modes plugin gives every conversation a thinking mode — one
named setting that decides which model answers, which model each background
role uses, and how hard the assistant reasons — plus a reasoning effort
rung the user can move within that mode.
Mode and effort belong to the conversation: switching one conversation never
changes another, and each user can set which mode their new conversations
start in.
Overview
Two modes ship out of the box and need no configuration, named after the two
ways a person works a problem — go and look, or sit and think it through:
| Mode | What it does | Starts at |
|---|---|---|
| ⚡ Fast | Answers from what it can see and look up, without stopping to reason it out first. The fastest setting. | off |
| ◉ Slow | Works the problem out before it acts. For design, subtle bugs and unfamiliar code. | medium |
The third entry every picker shows is Custom — the user's own per-role map,
which is the only place the combination of the two can be expressed (Slow for
the developer, Fast for the summariser). It is not declared here because it is
not a curated mode; see below.
Both built-ins name no model and the default stays Slow. Neither restricts
the dial: a mode decides where deliberation starts, and every rung the model
offers stays one click away in either. (They used to split the scale between
them, which on a model with a two-rung ladder left exactly one selectable value
per mode — see rung mapping.)
Slow starts at medium, and what that costs is the model's call. The mode
names an intent; the capability record prices it. On the shipped ardincoder-1
/ Qwen 3.8 record medium maps to low — measured indistinguishable from the
xhigh Slow used to send (−2.9 pts at p = 0.63) for 42% less per attempt and
40% less wall clock. On a model whose record maps nothing, medium is simply
the middle, and a deployment that wants deeper writes
thinking_modes.modes.slow.effort.default: high.
The set used to be Fast / Balanced / Deep. balanced named no rung, so it
ran at the model's own default — which on a model like Qwen 3.8 is its top
rung, making Balanced and Deep the same request. Three names for two
behaviours, with the middle one secretly the deepest. Both retired ids still
resolve to slow on every surface (/mode deep, a stored user default, an old
client), so nothing that used to work stops working. A deployer who declares
their own mode called deep owns that name — a declared mode always wins
over the alias.
Why the modes are named after
Kahneman's book, what we measured,
and the three places the analogy stops being true are on that page. The short
version worth having here: Fast is not a smaller assistant. It starts with
deliberation off, and off is where our own benchmark measured more tool
calls per solved task, not fewer — 70% fewer output tokens, a third of the wall
clock, completion unchanged on that tier. It substitutes looking things up for
reasoning them out. That is why Fast is a usable mode rather than a corner cut,
and why nothing in this plugin's copy grades a mode's intelligence.
And it is not free. That tier was saturated — seven tasks the model
already solves, where completion cannot fall. On tasks the model only partly
solves, the same setting cost most of the completion: 37/105 → 4/105 on one
screen, 45/65 → 26/65 on another, per task 5/20 → 0/20, 10/20 → 0/20, 12/20 →
2/20. So Fast trades completion on hard work for speed, deliberately, and a
deployer replacing these modes should keep that trade visible rather than
describe Fast as the free win. low — Fast's second notch — is the rung with
the flattering numbers (−14% cost per solved task, no detectable completion
difference), and which of the two Fast should start at is being measured:
evals/harness-opt/passes/11-fast-mode-bottom-rung/.
One mechanical caveat too: off is a rung a model may not have. Where the
capability record says control: always, Fast clamps up to the cheapest rung
that model does offer, and the picker says so.
A deployer can replace both with their own curated set (below) — for example a
Slow mode that also switches the main agent to a stronger endpoint while
leaving the summariser on a cheap one.
Reasoning effort — labelled Deliberation in the UI — is a closed
ordinal scale, off · minimal · low · medium · high · xhigh · max, and an
intent, not a token budget. max sits strictly above xhigh. What it moves
is how long the assistant reasons before each step — not how many steps it
takes, and not which model answers. Each rung's behaviour, which is the tooltip
on a selectable segment:
| Rung | What it does |
|---|---|
off | No deliberation — it acts, and looks up what it needs. |
minimal | A glance before it acts. |
low | A brief check before it acts. |
medium | The model's own habit, steered neither way. |
high | Most of the thinking before the first move. |
xhigh | The whole problem worked out before the first move. |
max | As long as it takes before the first move. |
The ordinal words stay on the segments, because a dial has to be ordered — but
read the scale as which instruction the model is given, not as a quantity of
intelligence. On a Qwen 3.8 template the injected instruction measures 41
tokens at low, 11 at medium and 53 at xhigh, constant to the token: the
real ladder is be brief / nothing / think exhaustively. **medium is the
model unsteered, not a midpoint**, and on our own benchmark it completed 22
points fewer tasks than either steered rung — worth knowing before making it a
mode's default.
What a rung means on the wire is decided per model by the reasoning: record
on its profile: levels are that model's own tokens and are sent as-is (see
Model Profiles and the models: section of the admin
configuration). The picker shows those tokens, not a global seven-rung scale.
A rung a model cannot honour is never sent: it is clamped to the nearest one
the model supports, so the endpoint never sees a value it would reject.
Two rules are worth knowing because they are easy to be surprised by:
- Roles a mode does not list inherit its main model, and a mode that names no main model leaves model selection exactly where the
models:block already put it. - Background roles reason at the mode's default effort, not at whatever the user is currently set to. Turning a conversation up to max makes the assistant think harder; it does not make the summariser think harder too.
Choosing a mode
In Settings → Models
Settings → Models is where a user decides what their conversations run on
when they are not choosing per conversation. From top to bottom:
- Default for new conversations — a model (the composer's list, grouped by connection and badged by where it runs) and an effort dial for that model's ladder. It says whether this is the organization's default or the user's own choice, and Use the organization's default hands it back. Nothing here changes a conversation you already have — that is the composer's job, and one conversation never moves another.
- Connections — when the Model Profiles plugin is loaded, its
<x-model-profiles-connections>element: the organization's connections with a per-user "show in picker" toggle, your own ones, and Add connection. Model Profiles does not list its own Extensions row then. - Background work — Helpers (Same as the chat, the default, or a model) and Housekeeping (Automatic, the default, Same as the chat, or a model). Automatic says what it resolves to for the default chat model ("With Sol, Automatic is Luna on Company OpenAI"). A chosen model says under which chat connections it applies: Settings accepts any choice, and the privacy rule (one line under the two) skips it for a chat on a more private connection.
- Who does what — read-only, for the default chat model: each role's model, connection, rung and why (tier default, admin setting, your earlier setting, moved: more private, kept on the chat model: context too small). Per-role assignments migrated from Model Profiles are listed with a Clear button; the administrator's per-role pins are listed read-only with where they are configured.
There are no mode cards, no Custom and no role-map editor on the web any more
(thinking-modes-ux-rethink P5); the terminal UI's pane still has them until
P6.
Models is a root Settings item, not an Extensions row. This plugin declares
a settings_pane (id: models, titled Models / Modellek), one of the few
plugins that qualifies for one — see
Settings panes. The pane sits right after
Shared in the sidebar, Extensions lists no Thinking Modes row, and an old link
to that row (openSettings("extensions", { pluginId: "thinking_modes" }))
lands on the pane. The composer's "Manage models…" opens it too. In the
terminal UI it is the Models entry of the Settings sidebar, shown when
Model Profiles is loaded.
These API routes back that page, none of them conversation-scoped:
GET /ui/plugins/thinking_modes/settings
→ { default: { model, effort, resolved_effort, source, model_title,
model_short, connection },
user_default: { model, model_explicit, effort, … } | null,
models: [ …the grouped picker list, see below… ],
edition,
background: { …GET /background… },
connections: { available, element },
modes, default_mode, builtin, roles, role_maps } (the TUI's, until P6)
PUT /ui/plugins/thinking_modes/user-default
{ "model": "company-openai/gpt-5.6-sol", "effort": "high" }
(or the legacy { "mode": "slow" }) → the same payload, updated
GET /ui/plugins/thinking_modes/background
→ { helpers, housekeeping, defaults, effective, deployment_ref,
applies: { helpers, housekeeping },
chat, automatic, roles: [ …who does what… ],
pins: [ … ], admin_pins: [ … ], admin_default }
PUT /ui/plugins/thinking_modes/background
{ "helpers": "same_as_chat", "housekeeping": "automatic" }
DELETE /ui/plugins/thinking_modes/pins (all legacy pins)
DELETE /ui/plugins/thinking_modes/pins/{role} (one; 404 when none)
Background work choices are same_as_chat, a model reference (@deployment
for the deployment model), or — for housekeeping only — automatic; null
means "not chosen", i.e. the deployment's model_selection.background.
applies[tier] is null for a choice that names no model, else {model, — the chat
model_title, available, connection, everywhere, under, not_under}
connections (those the picker may list a model of) the chosen model applies
under, and the more private ones it does not. roles is one row per role for
the model new conversations start on: {role, tier, model, model_id,.
model_title, connection, rung, why, why_text, refused}pins are the
per-role assignments migrated from Model Profiles — clearable;
admin_pins are models.subagent_profiles and a declared mode's roles
(origin: models.subagent_profiles, thinking_modes.modes.<mode>), and
admin_default is models.subagent_default — both read-only here.
connections.available is true only when Model Profiles is loaded, enabled,
and exposes user_model_overrides. The UI mounts
<x-model-profiles-connections> only then, once, and re-reads
GET /settings on its codumentor:model-profiles-changed event.
The PUT /user-default replaces the default, so an empty body ({}) clears
it; a body with only effort keeps the model inherited (model_explicit:). An unknown model or a rung off the scale is a 400, one of the user's
false
own connections the policy withholds a 403 with the reason; a rung the model
cannot honour is not refused (it clamps, as everywhere else). role_maps is
keyed by mode id, each row {role, profile, model, source, editable} — read by
the terminal UI only.
In a conversation
Model and effort belong to the conversation, so they are changed with a patch
on it:
PATCH /ui/plugins/thinking_modes/conversations/{conversation_id}/thinking
{ "model": "company-openai/gpt-5.6-sol", "effort": "high", "set_as_user_default": false }
Every field is optional and an omitted one is left alone. model is a model
reference (<connection>/<model>, or a profile name); model: null is the
deployment model. effort: null is the model's default effort. Switching model
without an effort carries the current rung over, or restores the one last
used for that model in this conversation. The legacy mode is still accepted
and translated (fast → effort off on the conversation's model, slow → the
default effort, a declared mode → its model). set_as_user_default: true also
makes the selection the starting point for your new conversations — kept for
API callers; the web composer no longer offers it (Settings → Models owns the
default).
The response is the same object the conversation snapshot carries under
thinking, plus what changed:
{
"selection": { "mode": "slow", "model": "company-openai/gpt-5.6-sol",
"effort": "high", "source": "conversation" },
"model": "company-openai/gpt-5.6-sol",
"model_title": "GPT-5.6 Sol", "model_short": "Sol",
"connection": { "id": "company-openai", "title": "Company OpenAI",
"owner": "organization", "privacy": "org_cloud" },
"models": [ { "id": "deployment", "title": "llm:1248", "kind": "deployment",
"owner": "organization", "privacy": "private",
"models": [ { "ref": null, "model_id": "ardincoder-1", "title": "…",
"short": "…", "rungs": [ … ], "default_effort": "medium",
"limited": null, "selected": false } ] }, … ],
"resolved": { "effort": "high", "requested_effort": "high",
"main_model": "gpt-5.6-sol", "effort_available": true,
"rungs": [ { "rung": "minimal", "enabled": true, … } ] },
"modes": [ … ],
"user_default": null,
"change": { "changed": true, "model_changed": true, "effort_changed": true,
"mode_changed": false },
"seam": { "text": "Switched to Sol · High (Company OpenAI)" }
}
models is the picker: the deployment model, every model of a declared
connection, and a legacy profile only when a model_selection.picker entry
names it — grouped by connection, the deployment model's group first, then the
user's own connections ("Yours"). The mode-shaped fields (selection.mode,
resolved.title, modes) are kept for the current composer chip and terminal
strip.
When personal connections are off for the caller
(model_selection.personal_connections: false, or their access profile's
personal_connections: false) the "Yours" groups are absent, and naming one
of those models in the PATCH, PUT /user-default or PUT /background answers
403 with the reason (an unknown model stays a 400).
A conversation whose stored model is no longer available runs its next
turn on model_selection.default.model instead (the deployment model when
that is unset), stores that, and gets the seam *ChatGPT is not available
here; using Ardincoder.* — the same plugin.thinking_modes.mode_changed
event, with unavailable naming what it fell back from and previous
describing the old selection (its reference, rung and model id). One path
for three causes:
- the connection became withheld (the policy above;
withheldis set too, and the connection's title is the name); - the reference now names another owner's model: every stored reference records whether it named the organization's model or the user's own (connection ids are one namespace, §4.2), so a
chatgpt/…stored while the organization had achatgptdoes not move onto the user's subscription when the admin removes it — nor the reverse when the organization adds one; - it names nothing any more — the sign-in was disconnected, the model dropped.
Going back is the user's pick, with a seam of its own. A Background work
choice or a legacy pin that became another owner's is skipped like one that
dangles.
GET on the same path returns everything except change and seam.
Two answers are worth reading rather than assuming:
resolved.effortis what will actually be sent, andrequested_effortis what was asked for. They differ when the model cannot honour the rung — asking for one it does not support is never an error, it is clamped.resolved.effort_available: falsemeans the effort control is dead for this conversation's model: either it has no reasoning control, or the endpoint refused one and the plugin stopped sending them.effort_unavailable_reasonsays which.- Each entry in
modescarries bothmainandmain_model.mainis the profile the mode declares (nullwhen it declares none and simply inherits);main_modelis the model id that resolves to for this user, which is what answers "would picking this switch the brain?".main_modelisnullonly forcustom, which declares nothing — see below.
When the model or effort actually changes, a marker is added to the transcript
— "Switched to Sol · High (Company OpenAI)", or "Effort: High" when only the
effort moved — so the conversation records which brain answered which
question. Re-picking what is already in force changes nothing and adds no
marker; markers written before this release keep their wording.
Outside the web UI
| Surface | How |
|---|---|
| Web | The chip at the left of the composer switches this conversation; Settings → Models (above) decides where new ones start. |
| Terminal UI | /model <name>, /effort <rung> and /thinking, typed in the composer — the same words as Telegram. The pair in force is always on screen in the composer's status strip (◉ Slow · High, shortened to ◉ High on a narrow terminal), and a change leaves the same marker in the transcript that the web shows. With the plugin absent there is no strip and /model is an ordinary message. |
| Telegram | /model <name>, /effort <rung>, and /thinking for the current pair. /model on its own lists the models by connection, /effort on its own the rungs. Anything else beginning with / is passed to the assistant untouched. |
codumentor -p | --model <name> and --effort <rung>, e.g. codumentor -p --model Sol --effort high "why is this test flaky?". --model selects a picker model only when the value is exactly its reference, short name or title; any other value is, as before, the agent model id (models.agent) for the run. The pair is echoed on stderr; with --output-format stream-json it also appears in the init event. --effort applies to print mode only. |
| OpenAI-compatible API | Send a picker model's reference, short name or title as the request's model field — {"model": "Sol", …} — exactly, not fuzzily. A mode name ({"model": "fast"}) keeps working. Any other value keeps its existing meaning (the field is otherwise ignored). GET /v1/models lists codumentor-agent, the picker models by reference, and the mode names. |
Naming a model. /model matches the short name, the title or the
reference, ignoring case: an exact name first, then a name it is the start of,
then the closest spelling when one model is clearly closest. When several
models fit equally (/model gpt with two GPT models) it lists them instead of
picking one; the reference always settles it.
/mode and --mode are retiring (one minor release): /mode fast is
/effort off, /mode slow is /effort at the model's default, a declared
mode is its model and rung, and each reply (or, for --mode, a warning on
stderr) says what to type instead. /mode custom points at Settings → Models.
A mode name that does not exist, or a rung that is not on the scale, is
rejected and told to you; a rung that exists but the model cannot honour is
accepted and clamped.
Users who already assign their own models
Custom is the mix of the other two, per role. Fast and Slow are
whole-assistant settings; a person cannot run one for part of a problem and the
other for the rest, but the assistant can — Slow for the developer role, Fast
for the summariser, the auto-titler and the web retriever. That is the whole
reason it is the third entry in the picker rather than an escape hatch.
Mechanically: if a user has set per-role assignments through
Model Profiles, those assignments keep working and appear
as an implicit Custom mode. They are read once (and again whenever they
change) into this plugin's own store: main_agent (else default) becomes the
model new conversations start on, default becomes the Helpers and
Housekeeping choice, and each subagent_overrides entry becomes a per-role pin
listed under GET /background, read-only and clearable. Model Profiles keeps
its copy for rollback. Nothing changes for such a user until they pick another
model or mode; their own named profiles stay available.
Custom is never lost: it is listed in Settings → Models
whether or not any assignment exists, so it can be made the default for new
conversations. In a conversation, pick the model with /model (/mode custom
now answers with a pointer to Settings → Models).
Configuration
plugins:
- module: codumentor.plugins.thinking_modes
class: ThinkingModesPlugin
priority: 6
args:
enabled: true
Enabled in the default shipped config. Register it at a **higher priority
number than model_profiles** (which ships at 5) so a mode layers on top of a
user's own assignments rather than the other way round.
| Parameter | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether the plugin is active. |
data_dir | string | agent storage dir, else ./data | Where the per-conversation selections are stored. |
max_conversations_per_user | int | 2000 | How many conversations' selections are kept per user before the oldest are dropped. |
Declaring your own modes
Deprecated.
thinking_modes:is read for one more minor release and
translated tomodel_selection:; the startup warning prints the replacement
lines. Since the translation a mode is only a shortcut for a model and an
effort: itsrolesare global per-role pins for every conversation (whichever
mode or model is picked, also whenmodel_selection:is declared),
effort.allowedandpromptare no longer applied, and a mode'smainno
longer overridesmodels.subagent_profiles.
Modes live in a top-level thinking_modes: section of the main configuration,
next to models: — not in the plugin's args:, because they reference the
profiles declared there and are cross-checked against them when the config
loads.
thinking_modes:
default: slow # mode for a user who has expressed no preference
modes:
fast:
title: Fast
description: Answers from what it can see and look up. Quick turns.
icon: bolt
cost: 1 # 1-3 relative badge shown in the picker
main: local-qwen # a name from models.profiles
effort: { default: "off" } # starts at off; the user can still turn it up
slow:
title: Slow
description: Plans before acting, strongest model for code changes.
icon: brain
cost: 3
main: cloud-strong
roles:
context_summarization: local-qwen
explore: local-qwen
effort: { default: medium, allowed: [off, minimal, low, medium, high, xhigh] }
# a ceiling — never `max` on the cloud model
prompt: |
Before editing, write a short plan and verify it against the code.
Declaring any mode replaces both built-ins entirely, and takes the
balanced/deep aliases with them: a set that declares neither slow nor a
mode of its own by those names leaves those two words naming nothing. Keep the
descriptions describing behaviour rather than grading capability — a mode
called "the good one" tells the user their other choice is the bad one, which
is not what the dial does.
| Field | Type | Default | Description |
|---|---|---|---|
title | string | required | Label shown in the picker. |
description | string | "" | One line under the title. |
icon | string | unset | Icon token for the picker. |
cost | int 1–3 | 2 | Relative cost/speed badge. |
main | profile name | unset | Model the chat runs on when the mode is picked; it becomes a picker entry. Unset is the deployment model (models.agent*). |
roles | map | {} | role → profile name: a per-role pin for every conversation, not just this mode's. |
effort.default | rung | unset | Rung the mode starts at. Unset means "whatever the model's own default is". |
effort.allowed | list of rungs | unset | Accepted and ignored: model_selection: has no effort ceiling (named in the startup warning). |
prompt | string | unset | Accepted and ignored: mode prompt fragments are dropped (named in the startup warning). |
Roles you may name in roles: are subagent, agentic_developer,
agentic_memory_store, context_summarization, explore, goal,
web_retrieve, web_search, auto_title, translation, voice_mediator,
plus any role you already point at a profile in models.subagent_profiles.
Write "off" in quotes: unquoted off is a YAML boolean. (Both are accepted —
this is a readability note, not a requirement.)
Notes
- A bad mode fails at startup, not at the first turn. A
main:orroles:entry naming a profile that does not exist, a role nobody answers to, a default effort outside the allowed list, or adefault:naming no mode — all of them stop the configuration from loading, and the error names every problem it found at once. - Switching modes mid-conversation is allowed and cheap to reason about, but not free. A different model — or a different effort on the same model — means a cold prompt-prefix cache for that conversation's next request.
- A mode never widens what the assistant may do. It selects models and a reasoning effort; tool access stays whatever the access profile allows.
- What is stored: a mode name and a rung per conversation, plus a per-user default. Nothing sensitive, so unlike Model Profiles' API keys there is nothing here to encrypt.
- Plugin authors can read the active mode for a turn from
ctx["thinking_mode"]andctx["reasoning_effort"], both set beforeonPromptAssemble, and can subscribe to theplugin.thinking_modes.mode_changedconversation event to see a switch as it happens.