Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3f8aa2f
Give hey mcp its own sign-in, and send a draft only on the person's c…
jeremy Oct 23, 2026
13c80ff
Close the gaps review found in send_draft's safeguards
jeremy Oct 23, 2026
75e2c51
Close round two's gaps in send_draft and the device sign-in
jeremy Oct 23, 2026
47dc338
Date the test's earlier mail so creation and delivery fall on either …
jeremy Oct 23, 2026
495d641
Deliver an attachment as its identity alone, and pin hey mcp to its s…
jeremy Oct 23, 2026
e5e1063
Show delivered header text whole, escape every invisible character, a…
jeremy Oct 23, 2026
ecd7973
Measure every delivered space, and key claims by the canonical origin
jeremy Oct 24, 2026
61f0644
Escape every default-ignorable character in the confirmation, not onl…
jeremy Oct 24, 2026
bd2791a
Send a draft in one request: no refresh-and-resend after a 401
jeremy Oct 24, 2026
b0d07de
To someone new, send exactly the plain text the confirmation shows
jeremy Oct 24, 2026
9f1e95d
Revalidate the grant just before the claim, and drop invisibles befor…
jeremy Oct 24, 2026
72f3ccc
Refuse on a failed renewal, acknowledge applied writes, keep spacing,…
jeremy Oct 24, 2026
0c62b58
Normalize the plain path's text and subject, and check acknowledgemen…
jeremy Oct 24, 2026
21bec94
Merge remote-tracking branch 'origin/main' into converge-598
jeremy Oct 28, 2026
8ca4af5
Say a Keychain refusal isn't a missing sign-in when hey mcp starts
jeremy Oct 28, 2026
ccd17b2
Name no custom element in a send refusal
jeremy Oct 28, 2026
cf1c83a
Bump golang.org/x/net to v0.62.0 for GO-2026-6866 and GO-2026-6860
jeremy Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ hey attachment save --output
hey auth
hey auth login
hey auth login --cookie
hey auth login --mcp
hey auth login --no-browser
hey auth login --token
hey auth logout
hey auth logout --mcp
hey auth refresh
hey auth status
hey auth token
Expand Down Expand Up @@ -281,9 +283,11 @@ hey label view --limit
hey label view --page
hey login
hey login --cookie
hey login --mcp
hey login --no-browser
hey login --token
hey logout
hey logout --mcp
hey mcp
hey mcp --domains
hey mcp --read-only
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,10 @@ The setup wizard connects the agents it finds. To do it yourself:
```bash
hey setup claude # skill + the hey@37signals plugin for Claude Code
hey setup codex # skill for Codex
claude mcp add hey -- hey mcp # HEY as MCP tools, on your signed-in account
hey auth login --mcp && claude mcp add hey -- hey mcp # HEY as MCP tools, on hey mcp's own sign-in
```

`hey mcp` saves drafts and never sends mail. You send a draft from HEY or with `hey draft send <id>`.
`hey mcp` saves drafts, and sends one only when you confirm that email in your MCP client.

## Docs

Expand Down
79 changes: 68 additions & 11 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,39 @@ Codex keeps in its log.
## MCP server

`hey mcp` runs an MCP (Model Context Protocol) server on stdin/stdout, serving HEY
boxes, search, threads, contacts, todos, calendars, and your identity as tools
backed by your signed-in account — the same keychain-stored credentials every other command uses.
Register it with any MCP client as a stdio server:
boxes, search, threads, contacts, todos, calendars, and your identity as tools.
Sign it in once, then register it with any MCP client as a stdio server:

```bash
hey auth login --mcp # hey mcp's own sign-in (read, write and send)
claude mcp add hey -- hey mcp # Claude Code
hey mcp --read-only # serve only read-only actions
hey mcp --domains boxes,search # narrow the served tool surface
```

### Its own sign-in

`hey mcp` doesn't use the CLI's credential. `hey auth login --mcp` signs it in as its own
app, "HEY MCP (local)" in HEY's Connected Apps, asking to read, write and send but nothing
more: a model driving `hey mcp` can't reach your settings, forwarding, trash or HEY World,
which the CLI's own sign-in can. The credential is kept apart from the CLI's, in its own
keychain item (or its own entry in `credentials.json` when there is no keychain), and it
refreshes as that app. `HEY_TOKEN` never stands in for it. Without it, `hey mcp` exits and
says to run `hey auth login --mcp`; it doesn't fall back to the CLI's credential, even
read-only, because that one holds full access and `--read-only` is hey mcp's own rule,
not HEY's.

The sign-in uses a device code where HEY offers one (it prints a link and a code to check),
and the browser otherwise. If you uncheck sending on HEY's page, `hey mcp` saves drafts
but serves no way to send them; sign in again to allow it. Without write it serves only
reads. `hey auth status` shows both sign-ins, and `hey auth logout --mcp` revokes hey mcp's
at HEY and removes it, leaving the CLI signed in.

The app's client id comes from `HEY_MCP_CLIENT_ID`, or from `mcp_client_ids` in
`~/.config/hey-cli/config.json`, keyed by HEY origin
(`{"mcp_client_ids": {"https://app.hey.com": "..."}}`). A repository's `.hey/config.json`
can't set it.

Each domain is one gateway tool (`hey_boxes`, `hey_search`, `hey_threads`,
`hey_contacts`, `hey_todos`, `hey_calendar`, `hey_identity`) dispatching actions
derived from the HEY SDK's API model; call an action named `describe` for any
Expand All @@ -85,10 +108,10 @@ the caller rather than risking a duplicate delivery, so retry a failed write
yourself once you know it did not land. Logs go to stderr — stdout carries
the MCP wire protocol.

### Drafts, never sends
### Drafts first, sends confirmed

`hey mcp` never delivers mail. `create_message`, `create_reply` and `update_message`,
the three actions that can send on HEY's API, always save a draft: the entry's status is
`create_message`, `create_reply` and `update_message`, the three actions that can send
on HEY's API, always save a draft: the entry's status is
forced to `drafted`, and the entry takes only its recipients (`entry.addressed`). A
call that asks to send (any other `entry.status`), schedule (`entry.scheduled_delivery`
and its date and hour) or triage the thread in the same request (`mark_done`, `pop`,
Expand All @@ -100,8 +123,42 @@ mail may have been sent, so check HEY before trying again. A save that fails wit
answer from HEY (a dropped connection, a timeout, a 5xx) may still have been saved, and
its error says to check Drafts before retrying.

There is no send action yet. A person reviews the draft and sends it from HEY, or with
`hey draft send <draft_id>`. Earlier releases delivered mail when a call omitted
`entry.status`. An MCP client that relied on that now gets a draft. `describe` for these
actions serves the draft-only schema, and the `hey_threads` tool description says the same.
`--read-only` still drops every write action, drafts included.
Delivery is one action in a tool of its own: `hey_send`'s `send_draft`, with the draft's
id. Each call asks you to confirm the email in your MCP client (an MCP elicitation),
showing the sending address, To, Cc and Bcc (a dash when empty), the subject, attachments
and the body's first 1,500 characters, with every control and invisible character
escaped so a display name can't forge a line. Recipients you hadn't emailed before
`hey mcp` was signed in are marked new. What you approve is what goes: the draft is
fetched again after you answer, and if its recipients, sender, subject, body or
attachments changed it isn't sent. Each draft is sent through `hey mcp` once; another
attempt in the next 24 hours, even after a failure, is refused, and the send is never
retried, so an unclear outcome says to check Sent.

To someone you hadn't emailed before, or within ten minutes of a read that may have
returned an unscreened sender's mail, the draft goes as the plain text the confirmation
shows: its formatting isn't sent, and invisible characters and trailing spaces are
dropped, so what goes is exactly what you read. To people you've emailed before, it
goes as it is. `send_draft` refuses, pointing at the
draft in HEY:

- any draft, from a client that can't show a confirmation (it answers with the draft's
link, and saying yes in the chat doesn't send it);
- a draft that is already scheduled, or has no recipients, or more than 20;
- a draft with a reference a mail client loads by itself, such as a remote image;
- a draft with an attachment, or more than 1,500 characters, when it goes to someone
new or within the ten minutes below (otherwise such a draft goes, and the
confirmation says how much it can't show);
- a draft to someone new within ten minutes of a read that may have returned an
unscreened sender's mail (search, threads, messages, drafts, contacts, the Screener,
calendar recordings; box reads don't count);
- past 50 sends, or 10 new addresses, in a day on one sign-in.

That state (claims, the daily counts and the ten-minute marker) lives in
`~/.local/state/hey-cli/mcp-send-state.json`, under a file lock and fsynced, so two
`hey mcp` processes can't both send one draft. `send_draft` is served only when the
sign-in was granted sending, and never under `--read-only`.

Earlier releases delivered mail when a call omitted `entry.status`, and then saved
drafts only. `describe` for the draft actions serves the draft-only schema, and the
`hey_threads` tool description says the same. A person can still send any draft from HEY
or with `hey draft send <draft_id>`.
10 changes: 10 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ hey auth logout # clear credentials

`hey login` and `hey logout` are top-level shortcuts for `hey auth login` and `hey auth logout`.

`hey mcp` has a sign-in of its own, kept apart from the CLI's and limited to reading,
writing and sending (see [agents.md](agents.md#its-own-sign-in)):

```bash
hey auth login --mcp # sign hey mcp in
hey auth logout --mcp # revoke hey mcp's sign-in and remove it
```

`hey auth status` reports both.

### Linked accounts

One HEY login exposes every mail account linked to that identity. List the available
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ require (
github.com/yuin/goldmark v1.8.6
github.com/zalando/go-keyring v0.2.8
golang.org/x/mod v0.42.0
golang.org/x/net v0.61.0
golang.org/x/net v0.62.0
golang.org/x/sync v0.24.0
golang.org/x/sys v0.49.0
golang.org/x/term v0.47.0
golang.org/x/text v0.43.0
)

require (
Expand Down Expand Up @@ -121,7 +122,6 @@ require (
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/text v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,8 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
Expand Down
Loading
Loading