# ADR-031 — "Send to": hand dictated text to a desktop AI client, pre-filled and never sent

**Status:** Proposed (2026-10-22)
**Deciders:** Mohsen Seyedkazemi Ardebili
**Context links:** [[adr-030-agent-era-direction]] (Pillar 2, bridge 1),
[[adr-019-egress-inventory-and-escalation]] (*Reached by handing a URL to another program*),
[[adr-v2-132-assisted-bug-reporting]] (option (b): prepare, never send — the precedent),
[[adr-021-invest-in-error-cost]], [[adr-011]].

Evidence labels: **[M]** measured in this repository · **[F]** a primary source says so
(accessed 2026-10-22) · **[O]** engineering judgement · **[U]** not verified.

---

## Context

A user who dictates a prompt for a desktop AI client can already do it: focus the client's
input box, hold the hotkey, speak. YazSes types into it like any other window [M]. Two
things make that worse than it needs to be:

- the dictation has to start with the client focused and the caret in its input box, so a
  thought spoken while reading a document or a terminal has to be re-spoken or copied;
- a long prompt dictated into a chat box that sends on Enter is one mis-recognised
  "new line" away from being sent half-finished.

Both desktop clients that the agent-era user runs most publish a URL scheme that opens a
new conversation with the prompt **pre-filled for review**:

| Client | URL | Behaviour (as documented) |
|---|---|---|
| Claude desktop (macOS, Windows, Linux) | `claude://claude.ai/new?q=<text>` | new chat, prompt box pre-filled, user reviews and sends; values URL-encoded; text cut at about 14,000 characters [F] ([Open Claude Desktop with a link](https://support.claude.com/en/articles/14729294-open-claude-desktop-with-a-link)) |
| Claude Code, in Claude desktop | `claude://code/new?q=<text>` | same, for a Claude Code session [F] (same page) |
| Codex app | `codex://new?prompt=<text>` | new local thread with the prompt; does nothing without a `prompt`, `path` or `originUrl` parameter [F] ([Codex app commands](https://developers.openai.com/codex/app/commands)) |

A third-party security report claims that a crafted `claude://` link could submit a prompt
without a send action [U]. It contradicts the vendor's documentation; it is the reason this
ADR treats "never sent" as something verified per client and per release, not assumed.

There is no equivalent for Gemini, and no mail client is in scope (ADR-030 §4).

## Decision

### 1. A hand-off verb, off by default

A new opt-in capability, `sendto` (`[sendto] enabled = false`), adds one action:
**hand the current text to a named client, pre-filled**. The text is, in order of
preference:

1. the staged-dictation buffer, when staged mode is on — "send to Claude" becomes a commit
   target beside typing it, so a long prompt is reviewed in YazSes's buffer first;
2. otherwise the last dictation burst, *instead of* typing it.

It is reachable as a spoken command (the exact phrases are the decider's, below), from the
CLI as `yazses send-to <client> [TEXT]` for scripts and keybindings, and it never types into
the focused window.

### 2. A closed list of targets, custom schemes only

Targets are a fixed table in a pure module (`sendto/targets.py`): `claude`, `claude-code`,
`codex`, each with its scheme, path and parameter name. **The user cannot configure a
URL.** A configurable template is a configurable exfiltration endpoint.

**No `https://` fallback, ever.** `https://claude.ai/new?q=` opened in a browser sends the
text to a server in the page request, *before* the user reviews anything [O]. That would
be an egress path that carries dictated text, which ADR-019 permits only through its
escalation rules. A custom scheme is resolved by the operating system to a locally
installed program; when the client is not installed, the hand-off fails and says so.

### 3. Why this adds no egress path

YazSes already places dictated text into these clients — by typing it. The hand-off moves
the same text into the same program by a different local mechanism; the client transmits
it only when the user sends it, exactly as with typed text. ADR-019 gets one row in
*Reached by handing a URL to another program*, classified like ADR-v2-132's pre-filled
issue: *dictated text → a local program; nothing is transmitted by YazSes*.

Two local exposures are new and are handled, not waved away:

- **The URL is visible locally while it is being opened.** An opener that spawns
  `xdg-open <url>` puts the text in a process argument list that other local users can
  read. The opener sits behind a new platform seam (`platform/base.py`, implemented per
  OS) that passes the URL by the platform's in-process API where one exists (macOS
  `NSWorkspace`, Windows `ShellExecuteW`) and documents the argv exposure where it does
  not.
- **Logs.** `system/browser.py::open_url` logs the URL on failure. The hand-off does not
  reuse it as-is: it logs the target and the text *length*, never the text.

### 4. Limits are refusals, not truncation

Text longer than the target's documented limit (Claude: about 14,000 characters) is
refused with the length and the limit, and stays in the staged buffer. A truncated prompt
sent to an agent is a different prompt.

### 5. "Never sent" is tested against the client, not the documentation

A validation row per client and OS records that the hand-off pre-fills and does not
submit, on the client version tested. The capability's `features info` names the client
versions it was verified on. If a client is ever observed to submit on open, its target is
removed in the next release and the release notes say why.

## Consequences

- One pure module (targets, encoding, limits), one platform seam, one spoken command, one
  CLI command, one config section, one ADR-019 row. No dependency.
- Linux is the first platform (ADR-030). macOS and Windows openers ship with it but stay
  *preview* until a validation row exists.
- The agent-era docs page can show "dictate, review, send to Claude" as one flow.

## Open questions for the decider

1. Spoken phrases: *"send to Claude"* / *"send to Codex"*, or a single *"send that to …"*
   with the target name? (Staged mode's own commit phrase is opt-in for the reason
   ADR-v2-132 gives; the same caution applies.)
2. Should `claude-code` be a separate target or a sub-mode of `claude`?
3. Is the CLI form (`yazses send-to`) wanted in the first release, or only the spoken one?

## Alternatives considered

- **Type into the client (today).** Kept — it remains the default and works everywhere.
  This ADR adds a path for the two cases in §Context.
- **A configurable URL template.** Rejected (§2): it turns a convenience into an arbitrary
  outbound channel.
- **An `https://` web fallback.** Rejected (§2): it transmits before review.
- **Driving the client's own API or a browser automation.** Rejected: it sends on the
  user's behalf, which ADR-030 §4 rules out.
