Skip to main content
Some tools return a value that must reach a live caller but must never be written down — a signing secret a provider shows only once, a token, a password a person just typed. The secret-value envelope carries such a value through the platform so it is revealed exactly once, at the live edge that asked for it, and shows a placeholder on every surface that records. Where config and secrets covers the secrets a server reads (referenced by env-var name, never embedded), this page covers the secrets a tool produces — the outbound half.

The envelope

SecretValue, from tai42_contract.secrets, wraps a real value inside a tool’s return:
The wrapper is deliberately hard to leak:
  • reveal() is the only way out. wrapped.reveal() returns the real value; nothing else does.
  • Its text form is a placeholder. repr and str both yield SecretValue([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 TypeError and 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 a SecretValue 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 through reveal();
  • 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.
A synchronous 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 SecretValue where 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 a SecretValue. 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 the tai CLI) at creation, before any handler sees it.
  • tai42_kit.logging.register_transport_redaction(name, markers=..., redact=...) adds a rule to the one filter on the httpx and httpcore loggers, 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:
Run it from the Studio or the run-tool API and the response carries the real 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:
The Stripe tools plugin ships this pattern for a real integration: creating a webhook endpoint returns the endpoint id plus its one-time signing secret wrapped in the envelope, so the secret reaches the caller that persists it but is written to no config, env, or file along the way. See config and secrets for the stored-secret side, presets for wrapping a secret-producing tool, and interactions for collecting one from a person.