The envelope
SecretValue, from tai42_contract.secrets, wraps a real value inside a tool’s
return:
reveal()is the only way out.wrapped.reveal()returns the real value; nothing else does.- Its text form is a placeholder.
reprandstrboth yieldSecretValue([secret]), so it cannot leak through a log line or an f-string. - It is not JSON-serializable — on purpose. Any un-audited path that tries to
dump it raises
TypeErrorand fails loudly, rather than quietly emitting the value. This fail-safe is what makes the wrapper safe to carry through code that was never told a secret is inside. - It compares and hashes by identity, not by value, so equality checks and set membership cannot become a timing or logging trap.
What each surface sees
The wrapper travels intact through the platform’s shared tool-result seam. Each door then decides for itself: a live edge reveals the real value once; every recording masks it to the[secret] placeholder.
The one handoff of a real secret is therefore a live, synchronous call — a
direct run-tool response, an MCP tool call, or a preset over one. A detached run
that still executes in-process — a background submit, a hook or trigger fire
— has no live caller to hand the value to, so it only records, and records the
placeholder. A run relocated to a backend worker — a scheduled run, a
backend-worker task — never reaches that in-process recorder: the wrapper does
not serialize onto the wire, so the run fails loudly rather than carry a secret
across the process boundary, by design. Plan a secret-producing tool to be
called synchronously by whoever consumes the value.
A model never sees a secret either: the adapter that feeds an agent’s model,
checkpoint, and callback trace masks the wrapper before the result leaves it.
When a preset pins an output schema and a secret-bearing
result violates it, the error keeps the failing JSON path but replaces the value
with
[secret]. The plaintext never rides a raised validation error into a log.
A preset’s output map never runs over a
secret-bearing result: the call is refused with preset '<name>' output map cannot run over a result that carries a secret, and the value is never quoted.A wrapped value as a tool argument
A tool validates its arguments against its own input schema before its body runs, and aSecretValue is not a string. Tool argument binding therefore reveals a wrapped argument where
that validation would reject the wrapper: wherever the tool’s schema types the position with a
concrete leaf type — a str field, a list[str] element, a typed model field, a
dict[str, str] value. A union counts as typed only when every branch is typed — str | None
is revealed, while str | Any or str | object keeps its permissive branch, so the value stays
wrapped. Wherever the schema leaves a position open — Any or object, or anything nested
beneath such a position — the value keeps its wrapper and is masked wherever the run is
recorded; a container under a permissive branch is left wholly wrapped, never descended into.
A preset’s resolved secret references
reach the base tool by this rule.
Sensitive questions
ask can collect a secret from a human. Pass
sensitive=True and:
- the answer comes back wrapped in a
SecretValue, so the tool reaches it only throughreveal(); - the durable question record keeps only the answered status — the answer body is never persisted;
- if the turn is killed on its timeout while a sensitive question is still
pending, the timeout error names the interaction but shows
[sensitive question]in place of the question text.
ask returns an AskAnswer whose answer is that SecretValue, so a tool
that returns the ask(..., sensitive=True) result directly is refused when its result
is written (UnencodableLeafError: a SecretValue is never encoded). Return
.answer — the SecretValue itself, which the run masks — or a value you derived from
reveal().
A reply quoting an answer to a sensitive question reads status: "sensitive" with no text:
the answer body is never kept, so there is nothing to quote.
The question text, though, is always visible wherever the question is
delivered and in the stored record. Never put a secret in the question itself —
ask for the secret, do not restate one.
Author rules
- Wrap at the return site. Construct the
SecretValuewhere the value is produced and put it straight into the return. One wrap protects the value on every surface downstream; there is nothing else to remember. - Consume a secret where you receive it. Reveal it and act on it — persist it through the config API, hand it to the provider call that needs it — in the same live call that produced it. Do not pass a wrapper onward as a task or chain result: cross-process transit is unsupported and fails loudly (the wrapper will not serialize onto the wire), by design, so a secret cannot silently ride a background handoff.
- Do not wrap shareable values. Ids, endpoint URLs, links, and other non-secret fields stay plain, so callers and models can read and act on them. Wrap only the value that must not be recorded.
Logging and redaction
A secret a library logs by itself — a token in a request URL, a credential in a logged config — never passes through aSecretValue. A plugin masks it by
registering a redactor with the kit, never by installing a logging hook of its
own:
tai42_kit.logging.register_record_redactor(name, markers=..., redact=...)adds a rule to the process record factory, which scrubs every record of the tai logger family (every record of the process under thetaiCLI) at creation, before any handler sees it.tai42_kit.logging.register_transport_redaction(name, markers=..., redact=...)adds a rule to the one filter on thehttpxandhttpcoreloggers, which render outbound request URLs.
markers are plain substrings; a record that carries none of them costs one
substring scan. A matching record is rendered once, passed through redact, and
its arguments cleared, so no formatter re-reads the raw values; exception and
stack text are scrubbed the same way. A redactor that raises fails the record
closed: its text becomes [log redactor error: record suppressed]. Registering
the same name again replaces the rule. For URLs in messages, the kit also
provides redact_url_userinfo (https://***@host/...) and redact_urls_in_text
(userinfo and every query-string value masked).
The Telegram channel keeps its bot token (which rides every Bot API path) out of
the httpx request lines this way:
Example
A generic tool that mints a one-time secret returns it wrapped, and writes it nowhere itself:secret once. A second tool consumes it in the same synchronous call — revealing
it only to write it into the env under a name the server later reads it back by:

