Document Work and To-do as the two product concepts, synchronize every shipped locale, publish the six-tool and typed-image contracts, and keep static web generation offline while live GitHub chrome refreshes after deployment.
29 KiB
/preview-request — see the outbound request without sending it
/preview-request renders a request manifest: a typed, redacted
description of the request the next primary agent turn would send. It never
sends that request, never appends to the conversation, and never writes engine,
session, or Work state. /dryrun and /preview_request are compatibility
aliases.
It is a human command. There is deliberately no model-visible tool for it.
/preview-request # session facts; route/body reported unavailable
/preview-request json # same manifest as JSON
/preview-request --prompt <text> # the next turn for that prompt
/preview-request json --prompt <text> # both
/preview-request base-prompt # exact base layer only; no runtime additions
Argument grammar
args := flag* [ "--prompt" <whitespace> prompt ]
flag := json | --json | manifest | --manifest | prompt | base-prompt | --base-prompt
prompt := every remaining byte, verbatim
Flags come before --prompt, which is terminal. Everything after it is
prompt text — including a trailing json. /preview-request --prompt fix it json previews the prompt "fix it json" as a human table; the JSON spelling
is /preview-request json --prompt fix it. There is exactly one reading of any
input, and an unknown argument before --prompt is rejected rather than
guessed at.
The prompt keeps your bytes. Repeated whitespace and newlines inside it
survive; exactly one whitespace codepoint separates --prompt from its text
and is consumed as syntax. Any additional leading whitespace, plus all
trailing whitespace and newlines, remains prompt data. The prompt is hashed
into the previewed body, so normalizing it would describe a request that
differs from the real one in the one field you typed.
prompt remains a compatibility alias for the ordinary protected manifest.
base-prompt / --base-prompt are an explicit, human-only disclosure mode:
they print exactly the effective base-prompt bytes and nothing else. They
cannot be combined with JSON or --prompt. Effective system text is never
printed because it can contain project instructions, skills, and memory.
--prompt is necessary for an exact manifest, and not always sufficient
The next user message is part of the request. Without it there is no next-turn body to describe, and under auto model routing there is not even a route: the route is decided by the text you have not typed yet.
So the manifest is sectioned, and each section is either exact or typed-absent:
| Section | Exact when |
|---|---|
session |
always — posture, gates, base-prompt provenance, requested model/reasoning |
route |
--prompt was supplied, an active goal has not exhausted its token budget, a fixed route is selected, no message_submit hooks are configured, and the shared planner resolved that route |
tools |
the route is exact and the MCP tool state can be snapshotted without connecting |
body |
the tool surface and authoritative Work snapshot are exact and no runtime transform would rewrite the request first |
Why a section stops being exact
| Typed reason | What a real turn would do that an inspection may not |
|---|---|
auto-route-unresolved-until-next-prompt |
decide the route from text you have not typed |
auto-route-classification-not-executed |
call the provider-backed Auto classifier; preview is strictly offline, so production must resolve it |
no-hypothetical-prompt-supplied |
send a message the manifest does not have |
message-submit-hooks-not-executed |
run mutable hooks that can rewrite or block the text — and therefore the route, tool policy, and body derived from it |
prompt-resolution-failed |
fail the same way on skill authority or file mentions |
route-plan-failed |
fail route resolution or preflight |
mcp-state-not-snapshottable |
connect MCP servers and discover tools this catalog does not contain |
runtime-transforms-before-send |
auto-compact, run context-overflow recovery, inject a background-shell completion, allow a running or terminal-undelivered child to complete, or flush pending LSP diagnostics before the first request |
work-state-not-snapshottable |
read the current graph-backed Work projection; preview never substitutes an asynchronously published, potentially stale To-do view |
goal-token-budget-exhausted |
stop the active goal before dispatch because durable token usage has reached the configured budget |
goal-state-not-snapshottable |
decide whether the active goal's terminal budget gate permits another request |
request-preparation-failed |
build no body at all |
mcp-state-not-snapshottable takes the body with it. A catalog missing its
MCP contribution is not "the same request with no MCP tools" — a real turn
connects, and may send a different tool list, a different tool region, and
therefore a different body and hashes. The body inherits the tool
section's reason rather than publishing an exact hash of a request that would
never be sent. The route section survives: the endpoint, dialect, and wire
model do not depend on which tools are on the request.
Detection is read-only. Nothing is drained, received, flushed, or compacted to find out: eligible running and terminal-undelivered children are checked, LSP blocks are checked for emptiness, the shell manager is checked without polling or marking completions reported, and the compaction decision is evaluated against the borrowed hypothetical message list with its active slop gate pinned. Inspecting the pending state does not consume it.
Without --prompt the route section is unavailable even on a fixed model.
That is deliberate: a route is only reported when it was resolved by the same
planner that would send the turn, for the same next message. A route that was
"probably still the current one" is exactly the kind of almost-true fact this
command exists to avoid.
An unavailable section publishes a typed reason and no fields. When auto
routing is unresolved there is no provider_id, route_id, dialect,
endpoint_host_class, endpoint_fingerprint, wire_model, billing,
tool_surface_budget, or body_sha256 anywhere in the JSON — not a null,
not the previous turn's value. requested_model reads auto, because that is
what you actually selected.
What runs, and what does not
With --prompt, the preview executes the deterministic part of the
production path up to (but not including) the send:
- The prompt is resolved into model-facing content exactly as a real submit
does — the pending active skill it would be wrapped with (cloned, not
consumed), file mentions, git mentions, and the paused-command note — with
the same error propagation. What a submit does that an inspection may not is
run
message_submithooks; when any are configured the manifest says so and claims nothing downstream of the text. - For a fixed route, that content goes through the same shared route
planner (
plan_turn_route) thatspawned_dispatch_inneruses for a real turn: effective provider and model, route identity resolution, preflight, route limits, compaction policy, and reasoning-effort normalization. Auto stops before this step because the planner would call a model classifier. - The engine projects the planned route into a throw-away client — the same client construction a turn installs, without installing it.
- It rebuilds the tool catalog and narrows it with the same planner the turn
loop uses, composes the system prompt for that route's model and context
window, and appends the hypothetical user message through the same
constructor production uses (turn metadata, route stamp, and provenance),
then resolves an
autoreasoning tier against those messages the way the turn loop does. Production sends stored history and nothing else — Codewhale does not re-state the To-do list on model steps — so the previewed outbound message list is exactly that list, and one estimate over stored messages plus system covers both the manifest number and the overflow decision. - It prepares the request through
DeepSeekClient::prepare_outbound_requestand describes the result — unless a runtime transform would rewrite it first, in which case the body is typed unavailable instead.
Nothing is installed, not even briefly. Everything a turn installs before
building its request — the command-scoped tool gate, the effective mode and
approval posture, the policy-narrowing event, the working set with the new
message observed — is passed as a value or snapshotted onto a clone. There is
no write-then-restore: a restore is not atomic across an await, and it does
not survive a cancellation or a panic. Terminal continuation state is read
without mutating its counters. A regression test
asserts that config, caches, session messages, model, system prompt, working
set, provider, mode, and the MCP pool are all byte-identical afterwards.
No outbound call can happen. With a fixed model, planning and request
preparation are local. With Auto selected, the command reports
auto-route-classification-not-executed and stops before the shared planner,
because resolving the route would call a model classifier. Preview never
reads or populates the classifier response cache and never changes provider
retry or rate-limit state.
Nothing else has a side effect. The tool-catalog build runs in a passive
mode: it never creates the MCP pool, calls connect_all, reloads an MCP config
source, starts a server, spawns a sub-agent runtime task, captures a fork
snapshot, or emits a UI status event. When the connected MCP state is not
already exactly what a turn would use — no pool yet, a config source changed,
or an enabled server is not connected — the tools section reports
mcp-state-not-snapshottable instead of connecting to find out.
Scope: primary agent turns only
This manifest describes LlmClient::create_message /
create_message_stream — the model turns the agent loop runs. It does not
describe Codewhale's auxiliary provider calls, which have their own shapes:
| Auxiliary call | Status |
|---|---|
Chat-dialect translation (translate) |
Not on the prepared seam: it builds a small fixed body (no tools, temperature 0.1) directly. Out of scope. |
| Anthropic/Responses-dialect translation | Routed through prepare_outbound_request to avoid a second builder, but still an auxiliary call and still out of scope for this manifest. |
FIM completion, speech, provider-native search, /models listing |
Separate endpoints and bodies. Out of scope. |
| Auto-router classifier | A separate small turn on the router route. Out of scope and never executed by preview. |
Any claim that "every outbound request" goes through the previewed seam would be false, and this document does not make one.
Where the numbers come from
The prepared outbound request. Every primary model turn reaches the wire
through DeepSeekClient::prepare_outbound_request, which returns a
PreparedOutboundRequest: dialect, endpoint identity, canonical wire model,
the final body, and a reasoning receipt. Production dispatch sends that value;
the preview describes it. There is no second body builder.
Parity tests do not feed a captured logical request back through that builder. They run a real production turn against an HTTP mock, parse the first body the server actually received, canonicalize those captured bytes independently, and compare that hash with preview. The coverage includes translated prompt context, paused-command detach, and native Anthropic Messages shaping.
Preparation runs the full production sequence: tool-history repair and model-bound secret redaction, protocol binding and route model re-resolution, the dialect's own body builder with every provider-specific sanitizer and reasoning shaper, and exact endpoint resolution.
Every production dialect is preserved end to end — nothing is projected through Chat Completions:
| Dialect | Routes |
|---|---|
chat-completions |
DeepSeek, Moonshot/Kimi (including the Kimi Code K3 nested thinking.effort shape and the direct K3 fixed-sampling shape), Z.ai, xAI, OpenRouter, vLLM/Ollama/SGLang, OpenCode Zen chat routes, custom compatible endpoints |
anthropic-messages |
Anthropic, DeepSeek Messages, MiniMax Messages, OpenModel |
openai-responses |
OpenAI Codex (ChatGPT backend path), OpenCode Zen responses routes |
The manifest reports the dialect and the route shape
(standard, deepseek-beta-strict-tools, kimi-code-k3,
direct-moonshot-k3, codex-responses, opencode-zen,
custom-compatible), so you can see which builder branch actually ran.
The engine. The manifest is built by the engine, not the command layer, because only the engine can rebuild the exact next-turn tool catalog, active subset, gates, permission posture, and tool choice. The session's last tool catalog is never consulted — it is one turn stale and stores the pre-activation catalog.
Streaming is reported as a wire fact
caller_entrypoint says which transport entry point was described
(streaming / blocking). body_stream_field says what the body says,
read off the finished JSON:
- Chat Completions streaming →
true; Chat blocking → the field is absent (null), because the blocking body never carries it. - Anthropic Messages → mirrors the caller.
- OpenAI Responses → always
true, including on the blocking entry point, which opens an SSE stream and folds it into one response.
The manifest describes the body field exactly rather than inferring it from the caller, so the Responses blocking case cannot be misreported as a non-streaming request.
tool_choice is likewise read from the finished provider body, not the
logical request: Anthropic may carry an object, Responses carries its mapped
string, and DeepSeek thinking requests omit the field entirely.
What the manifest tells you
| Section | Fields |
|---|---|
session |
exact primary-agent role/lane/Fleet non-assignment, requested model (auto when auto), routing mode, requested reasoning, whether a hypothetical prompt was supplied, mode, approval posture, allow/deny gate sizes, base-prompt origin + bytes + SHA-256 |
route |
provider id + display name, named route id, typed routing source, dialect, route shape, safe endpoint host class/digest, endpoint fingerprint, wire model, caller entrypoint, body stream field, context ceiling + source (configured, provider-reported, static floor, catalog, or fallback), route input/output limits or unknown, typed billing |
tools |
active count, catalog / deferred counts, logical catalog SHA-256, surface budget, Standard-vs-Full collapse, MCP servers and MCP tools |
body |
reasoning resolution + wire control keys + wire effort and the key path it came from, tool_choice, system-prompt assembly + effective canonical JSON bytes/SHA-256, body/system/tool-schema/message/tool-result/attachment/framing canonical JSON sizes, per-class estimates, exact input-budget ceiling and headroom, literal wire output cap or unknown, provider-reported usage explicitly unavailable because no request ran, whole-body SHA-256, wire tool-schema SHA-256, local system/tools component SHA-256 |
Counts and estimates are extracted dialect-aware: a Responses body's
instructions/input, an Anthropic body's system/messages, and a Chat
body's inline system-role message are each read from where that dialect
actually keeps them.
The byte classes are an exact accounting, not byte slices
system + tool_schemas + messages + framing == body_canonical_json_bytes, exactly, in
every dialect and on both entry points. The first three are the canonical
serializations of selected JSON values; they are not four disjoint ranges
borrowed from the body buffer. framing is the algebraic remainder after
those selected values. It includes every other top-level field and whatever
JSON structure was not already counted inside a selected array value. An
invariant test asserts the sum identity, and mutation tests check the intended
attribution. Do not use these counts to reconstruct the request bytes.
tool_result and attachment bytes are subsets of the message bytes,
reported for attribution and never added again.
Headroom is measured against the input budget
estimated_input_headroom_tokens subtracts production's conservative
messages-plus-system estimate from this route's
input budget ceiling — context_input_budget_for_route, the same seam the
turn loop checks before it sends, which is the context window minus the output
reservation and the safety headroom. It is not the raw context_limit_tokens:
subtracting input from a window the route also has to fit its response into
reports headroom the turn does not have. The value goes negative when the
request would not fit, rather than clamping to zero and reading as "it fits" —
and when it does, the body is reported unavailable, because the turn loop would
run context-overflow recovery and send something else.
This production gate is distinct from the manifest's independent conservative estimate over canonical JSON body bytes. The latter remains useful for provider-body attribution, but it does not decide overflow or headroom.
The manifest publishes that exact ceiling as input_budget_ceiling_tokens.
It also keeps three different limit facts separate: the context ceiling and
its resolver source, optional route/offering input and output limits, and the
output cap literally serialized on the wire. If a route or dialect supplies
no such fact, the value is unknown; preview never fabricates one from a
neighboring model or an installed route.
Reasoning controls are read wherever the dialect puts them
reasoning_wire_effort reads the flat reasoning_effort, Kimi Code's nested
thinking.effort, the Responses reasoning.effort, and the Anthropic
output_config.effort, and reasoning_wire_effort_source names which one it
came from (a compile-time constant, never a key lifted out of the body).
Reporting only the flat key made the routes that think hardest read as "no
effort sent".
reasoning_resolution distinguishes an explicit user selection from a
route-default control the user never asked for, and reports
not-applicable when the body asks for no reasoning at all. A Responses
include field discloses reasoning output rather than requesting a tier, so
include alone is never reported as a reasoning request.
The whole-body hash
body_sha256 covers the complete canonicalized wire body, not a prefix. It
changes when any of these change: max-token fields, tool_choice, nested
reasoning controls, provider-transformed tool schemas, attachments, stream
options, sampling parameters, or any message — including the appended
hypothetical prompt. Canonicalization sorts object keys, so builder insertion
order alone does not move the hash. Any real input change does, including
date/working-set/git metadata, runtime injections, tool discovery, prompt
settings, or routing.
tools.active_tool_catalog_sha256 is a separate, stable hash over the current
active tool catalog before dialect shaping (name, description, canonical
logical schema, in order). It moves on membership, ordering, and logical schema
changes. It is a catalog identity, not a wire fact: two routes can agree here
and still send different bytes, because each dialect transforms schemas its own
way and strict mode sanitizes them further.
body.tool_schema_wire_sha256 is the hash of the tools region as the
provider receives it. body.local_system_tools_component_sha256 combines
that digest with the final wire system-region digest as a local comparison
fingerprint. It is not a provider cache key, does not claim those regions are
adjacent, and carries no route-specific cache-semantics guarantee. It is
omitted when the tool surface is not exactly known.
Disclosure boundary
The manifest is a fixed set of counts, hashes, enums, and short provenance labels. It has no field that can hold free-form request text. It cannot contain:
- the system prompt, project instructions, memory, or skill content;
- message content, tool-result bodies, or attachment payloads;
- credentials,
Authorizationheaders, or query strings; - URL paths (which can themselves carry a deployment secret);
- absolute workspace or home paths.
Identifiers are not trusted either. A custom route id and a model id are
user-authored text that can be an absolute path, a URL, a URL path, or a
deployment id that is itself a credential. Every such value crosses an
allowlist boundary (crate::safe_label) before it is printed: an
generic identifier is published verbatim only when it contains no slash. A
slash-bearing model id additionally has to match an exact entry in the active
local model catalog; a vendor-looking prefix is not sufficient. Anything else
is replaced by a stable sha256:<12 hex> fingerprint. Two previews of the same
hostile id still compare equal; the id itself is never shown.
Error text is not trusted either, and is not scrubbed — it is allowlisted. Preflight, MCP, prompt-resolution, and request-preparation failures all interpolate host text, and that text routinely carries a route id, a server name in quotes, an endpoint whose path is the secret, or a raw credential. Every whitespace-separated token has to earn its place:
- a token containing a control character is dropped;
- a URL keeps only
scheme://host[:port], and only when both are themselves ordinary — path, query, fragment, and userinfo are never published, and a token-shaped "host" makes the whole token opaque rather than half-published; - anything path-shaped (POSIX absolute,
~/, Windows drive, or containing a backslash) collapses to<path-redacted>; - a token carrying
",', or a backtick is replaced wholesale — quoted spans are where hostile identifiers hide; - everything else must be a short ordinary word (ASCII alphanumerics plus
-,_,., bounded in length, rejected if it looks token- or key-shaped), with only sentence punctuation allowed at its edges.
Anything else becomes <redacted>, runs of redactions collapse, and the result
is truncated. Ordinary diagnostic sentences survive intact; hostile ones become
a generic shape.
Endpoints are published two ways only: a bounded host class (http loopback
or https remote sha256:<12 hex>) and a SHA-256 fingerprint of the full URL
for "same endpoint?" comparisons. A remote authority is always digested — it
may be a credential-shaped tenant subdomain — and paths, IDNs, userinfo,
queries, and fragments are never shown.
This is an inspectability slice, not a request-body export. A regression test asserts that no manifest field carries a serialized message array or tool schema.
Estimates are estimates
Every token number is an offline estimate (~4 bytes/token plus a 5% conservative margin), never a provider-authoritative count. Use them to compare requests to each other, not to predict a bill. Byte counts, hashes, and counts are exact.
Tool-result and attachment estimates are subsets of the message estimate and are reported for attribution; they are not added again into the total.
Base-prompt provenance (#3928)
The protected manifest distinguishes three things without printing effective
system text. The explicit /preview-request base-prompt mode separately prints
only the exact effective base-prompt bytes:
- Origin — where the base-prompt bytes came from:
bundled in this codewhale-tui build (BASE_PROMPT, compiled in)config-directory override installed at startup (prompts/constitution.md, opt-in enabled)
- Assembly — how the effective prompt was built on top of that base:
base prompt only,base prompt + configured static layers, orbase prompt + configured layers + runtime/session additions. - Effective hash — the SHA-256 of the system region of the prepared request: the prompt in its final wire form, not an independently recomposed string.
In a live session the assembly is normally
base prompt + configured layers + runtime/session additions, because the
environment block, project context, skills, and memory are appended after the
constitution. Codewhale does not claim the configured constitution is the
effective base prompt, and no diagnostic cites a source-tree path that does not
exist on an installed binary.
Tool-surface labels
standard_and_full_surfaces_collapsed is derived, not asserted: the
surface shaper is run over the actual current catalog under both the Standard
and the Full budget and the results are compared. Today it reports true
— the two budgets produce the same catalog — and the manifest says so in
plain words rather than implying a difference. The day the shaper narrows
Standard differently from Full, the field flips on its own with no copy edit.
Any benchmark claim about one surface offering "more tools" has to show a
different active_tool_catalog_sha256 first.
Comparing routes before you spend anything
/preview-request makes a provider-free fixed-route A/B possible: switch route,
preview with the same prompt, compare.
- Select the route (
/model,/provider, or your profile) — do not send a turn. - Run
/preview-request json --prompt "<the same text each time>"and save the output, e.g.glm-5.2.json. - Repeat for each route.
- Diff the manifests.
What to read in the diff:
route.dialect/route.route_shape— two routes on different dialects are sending structurally different requests, not the same request to a different host.route.wire_model— the id that actually goes on the wire. Router entries frequently differ from the label you selected.tools.active_tool_count/body.tool_schema_wire_sha256— identical wire hashes mean identical tool bytes, whatever the surface label says. Usetools.active_tool_catalog_sha256only to compare logical catalogs; two dialects can agree there and still send different schemas.body.estimates.tool_schemasvsroute.context_limit_tokens— the fixed overhead each route pays before the conversation starts, against the window it has.body.reasoning_wire_control_keys/body.reasoning_wire_effort/body.reasoning_resolution— whether the route is actually being asked to think, in which dialect, and whether that came from you or from auto routing. Two routes with different effective effort are not comparable.body.body_sha256— the whole request. If it is unchanged, nothing about the outbound bytes changed.body.local_system_tools_component_sha256— whether the two locally measured wire components changed. It does not prove provider cache reuse or invalidation.route.billing— subscription quota and metered API routes are not cost-comparable, andunknownmeans cost reporting fails closed.
Each manifest is exact for the snapshot it describes. Repeated previews are identical only when every contributing input is identical: route, current date, git and working-set metadata, prompt settings, tool/MCP state, session history, and pending runtime transforms. Previewing does not mutate session history or the response cache, but no cross-call byte-stability claim is made. Auto is intentionally unavailable and cannot be used for this comparison until production resolves a concrete route.
schema_version is bumped whenever a field is renamed or removed, so scripted
consumers can detect an incompatible manifest instead of silently reading
null. The current shape is 9: v8's work-state-not-snapshottable
unavailable reason is gone, because no request carries a To-do block for a
snapshot to fail on. The active goal token-budget terminal gate remains an
explicit fail-closed dependency of an exact outbound request.
What is still approximate
Auto routing is not approximated: its provider-backed classifier is never run by preview, and the route-dependent sections are typed unavailable instead.
Working-set drift used to be listed here. It no longer applies: a real submit
calls working_set.observe_user_message before it builds <turn_meta>, and
the preview now performs that same observation on a clone of the working
set and builds the block from that snapshot. Same bytes, no session write.
Everything else that used to be "approximate" is now typed. If a runtime transform would change the request, the body section says so and publishes no bytes at all, rather than publishing numbers that are nearly right.
Credit
The dryrun concept — preview the next request from the real request-building
seam rather than a hand-rolled summary — was harvested from PR #1099 by
@GTC2080 (TaoMu). No code from that PR is
reused; the implementation here is written against Codewhale's current
multi-dialect client.