Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
18 changes: 18 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,24 @@ hey label view
hey label view --all
hey label view --limit
hey label view --page
hey local
hey local --socket
hey local call
hey local call --arg
hey local drafts
hey local imbox
hey local reply
hey local reply --message
hey local screener
hey local screener approve
hey local screener deny
hey local screener list
hey local send
hey local tools
hey local write
hey local write --message
hey local write --subject
hey local write --to
hey login
hey login --cookie
hey login --mcp
Expand Down
85 changes: 83 additions & 2 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

hey-cli is built to be driven by an agent as readily as by a person: every command that returns
data answers `--json`, the exit codes are stable (`hey help exit-codes`), and `hey commands --json`
describes the whole surface. This page covers the two integrations that ship with it: an
agent skill for Claude Code and Codex, and an MCP server.
describes the whole surface. This page covers the integrations that ship with it: an
agent skill for Claude Code and Codex, an MCP server, and `hey local`, which asks the HEY
app on your computer to act for you.

## Agent skill and Claude Code plugin

Expand Down Expand Up @@ -156,3 +157,83 @@ Earlier releases delivered mail when a call omitted `entry.status`; these action
save 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>`.

## The HEY app on your computer: `hey local`

`hey local` talks to the HEY desktop app running on your computer rather than to HEY's
servers. The app runs its own commands over the mail it holds, as you would in its window,
so `hey local` needs no sign-in of its own and nothing it reads is marked seen.

The app lets agents in only while "Let agents use HEY" is on in its Settings (General). It
is off by default. While it is on, the app listens on a Unix socket at
`$XDG_RUNTIME_DIR/hey/agent.sock`, owner-only, and turns away a connection from any other
user account. With the setting off or the app closed there is no socket, and `hey local`
fails with exit 6 and says to turn it on. `--socket <path>` or `HEY_LOCAL_SOCKET` points it
at another socket; with neither, and no absolute `XDG_RUNTIME_DIR`, it asks for one rather
than guess.

```bash
hey local tools # what the app offers now, and what each does
hey local imbox # the Imbox, marking nothing seen
hey local screener list # who waits in The Screener, and who was decided
hey local screener deny 42 # screen sender 42 out; undo with approve
hey local drafts # the app's drafts, and which it would send now
hey local write --to marcus@example.com --subject "Stint plan" -m "Saturday works"
hey local reply 801 -m "Saturday works" # a reply to Imbox thread 801, as a draft
hey local send 1731280000123 # ask to send a draft: you confirm it in HEY
hey local call screener.decide --arg sender=42 --arg decision=in
```

Each command the app offers names its effect, and the effect decides what happens:

| Effect | What happens | Commands |
|--------|--------------|----------|
| `read` | Runs, and changes nothing | `imbox.list`, `screener.list`, `drafts.list` |
| `reversible` | Runs at once, and can be undone | `screener.decide`, `draft.write`, `draft.reply` |
| `reaches_people` | Waits in the HEY window until you press Send there | `message.send` |

Nothing that can't be undone, like emptying Trash or Spam, is offered at all. A send never
goes from `hey local`: the app shows you the sending address, recipients, subject, body
and files exactly as they would go, and sends only when you press Send in its window.
`hey local send` then exits 9 and says "Waiting in HEY": neither done (0) nor failed.
It takes the HEY app's own id for a draft, not the id `hey draft list` shows: the id
`hey local write` or `hey local reply` answered, or one `hey local drafts` lists (its `id`,
or its `made_as`, the id it was written as before HEY named it). Its JSON envelope carries `meta.status: "pending"`, the confirmation's id and what you were
shown. No command or flag here can confirm it.

So an agent can carry a message end to end: `hey local write` (or `hey local reply` with a
thread from the Thread column of `hey local imbox`) saves a draft and answers its id, and
`hey local send <id>` asks HEY to send it, which waits for you. `--ids-only` prints the draft's
id. Writing sends nothing, and
each run writes one more draft: nothing here edits or deletes one, so a wrong draft is yours
to delete in HEY. The message is plain text, above your name tag; Cc, Bcc, files and a
schedule are yours to add in HEY. A reply answers the thread's last message and is
addressed as HEY's own Reply addresses one; it is saved beside any draft you have on the
thread, never over it. `hey local drafts` shows whom a draft goes to before you send it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The claim that hey local drafts shows whom a draft goes to is only true in JSON. The styled table shows just a truncated To column and omits Cc and Bcc. Name the JSON fields here, or say the table shows only To.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/agents.md, line 212:

<comment>The claim that `hey local drafts` shows whom a draft goes to is only true in JSON. The styled table shows just a truncated `To` column and omits Cc and Bcc. Name the JSON fields here, or say the table shows only `To`.</comment>

<file context>
@@ -207,8 +207,9 @@ thread from the Thread column of `hey local imbox`) saves a draft and answers it
-to, as HEY's Reply does, beside any draft you have on the thread, never over it.
+schedule are yours to add in HEY. A reply answers the thread's last message and is
+addressed as HEY's own Reply addresses one; it is saved beside any draft you have on the
+thread, never over it. `hey local drafts` shows whom a draft goes to before you send it.
 `hey local drafts` marks each draft HEY would send now as `sendable`; a scheduled one sends
 on its own at its time.
</file context>
Suggested change
thread, never over it. `hey local drafts` shows whom a draft goes to before you send it.
thread, never over it. `hey local drafts --json` shows whom a draft goes to (`to`, `cc`, `bcc`) before you send it; its table shows only `To`.

`hey local drafts` marks each draft HEY would send now as `sendable`; a scheduled one sends
on its own at its time.

The draft commands' refusals say what happened, and nothing was saved or waits:

| Refusal | Exit | Means |
|---------|------|-------|
| `not_an_address` | 1 | Pieces of `--to` read as no address; the message names them |
| `not_saved` | 1 or 7 | Past what HEY takes of one message: too many people, too many files, or too large. 1 for `write`, whose words and `--to` are yours to change; 7 for `reply`, whose recipients, files and quote come from the thread |
| `not_a_sender` | 7 | The draft goes from an address this account no longer sends from; change From in HEY, or write a new draft |
| `disabled` | 1, or 7 when HEY would send none | `send` of a draft HEY won't send now (scheduled, to nobody, a file still uploading, or a forward); the hint lists those it would |
| `unavailable` | 7 | `reply` of a thread the app couldn't read, or a draft `write` or `reply` saved that went before HEY listed it: check `hey local drafts` before writing it again |

`hey local call <command>` runs any command `hey local tools` lists. Arguments come as a
JSON object, as `--arg name=value` pairs, or both, the pairs over the object; a value is
read as JSON where it is JSON, so `sender=42` is a number and `decision=out` a word.
A command the app refuses fails with its reason in the message, and in the JSON error's
`meta.refusal`, which lists the ids it would take where an id was wrong.
A command cut off by signing out of the app or switching accounts while it ran also exits 7,
with `session_ended` in `meta.refusal`: it may have been done, so check in HEY before running
it again.

The wire is plain MCP: JSON-RPC 2.0, one message per line, `initialize`, then `tools/list`
and `tools/call`. The app's door is built for Linux first. A Mac has no socket yet: there,
HEY's sign-in sits behind a Keychain prompt, which a socket any program can reach would let
it skip, so the Mac app will first ask you to approve each agent and check the signature
of the program calling.
10 changes: 10 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,16 @@ Snippets are named reusable email content, separate from clips saved out of rece

Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Moving to any of them but Imbox marks the threads seen, and a thread that cannot be replied to is silently not moved to Reply Later. Reply Later is a box rather than a separate flag: moving a Reply Later thread to Imbox removes Reply Later, preserves its seen state, and leaves a seen thread in Previously Seen. It does not return the thread to the box it occupied before Reply Later. A bundle row is refused rather than moved: it stands in for one sender's whole stream in the box they are delivered to, so moving it leaves nothing there for their next email to join and it arrives unbundled instead. Group or ungroup a sender with `hey contact bundle` and `hey contact unbundle`, and read a bundle with `hey bundle view`. Bubble Up has its own commands: `hey bubble up` raises a thread right away with `--now`, on a date with `--on` (HEY resurfaces it at 08:00 UTC that day, or 18:00 UTC when the date is today in UTC), or at 08:00 UTC tomorrow, the next Saturday, or next Monday with `--tomorrow`, `--weekend`, and `--next-week`; `hey bubble pop` cancels one, moving the thread to the Imbox and marking it seen rather than returning it to its original box. `hey bubble list` shows both buckets — the threads back in the Imbox after bubbling up and the ones still scheduled, each with when it resurfaces. Trashing a shared thread removes your access instead of deleting it for everyone. Ignoring marks a thread seen and leaves it in its box; while it is ignored `hey unseen` has no effect, and `hey stop-ignoring` resumes notifications but leaves it seen.

## The HEY app on your computer

`hey local` asks the HEY desktop app on this computer to act for you, through the door it
opens while "Let agents use HEY" is on in its Settings: `hey local imbox`,
`hey local screener list`, `hey local screener approve`, `hey local screener deny`,
`hey local drafts`, `hey local write`, `hey local reply`, `hey local send`, and `hey local call` for any command `hey local tools` lists. The app
opens that door on Linux only, for now. It reaches the app alone, never HEY's servers, and needs
no sign-in. Writing or replying saves a draft and sends nothing; a send waits in the HEY window for you to press Send and exits 9, pending.
The effects, the socket and the exit codes are in [agents.md](agents.md#the-hey-app-on-your-computer-hey-local).
Comment thread
jeremy marked this conversation as resolved.
Resolved

## Watching for changes

```bash
Expand Down
19 changes: 19 additions & 0 deletions internal/cmd/accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ func TestCommandAccountScopePolicy(t *testing.T) {
{args: []string{"auth", "status"}, want: false},
{args: []string{"config", "show"}, want: false},
{args: []string{"shell-completion", "install"}, want: false},
// hey local talks to the app on this computer, never to HEY's server.
{args: []string{"local", "tools"}, want: false},
{args: []string{"local", "screener", "deny"}, want: false},
} {
command, _, err := root.Find(test.args)
if err != nil {
Expand Down Expand Up @@ -399,3 +402,19 @@ func runAccountsCLIWithConfig(t *testing.T, server *httptest.Server, configDir s
err := root.Execute()
return output.String(), err
}

func TestLocalSkipsWhatOnlyHEYsServerNeeds(t *testing.T) {
root := newRootCmd()
for _, args := range [][]string{{"local", "imbox"}, {"local", "send"}} {
command, _, err := root.Find(args)
if err != nil {
t.Fatalf("Find(%v): %v", args, err)
}
if !commandSkipsCredentialMigration(command) {
t.Errorf("%v migrates credentials it never reads", args)
}
if !commandIgnoresLocalConfig(command) {
t.Errorf("%v reads a repository's server and account config", args)
}
}
}
4 changes: 4 additions & 0 deletions internal/cmd/help_topics.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ EXIT CODES
7 An API, server, or local operational failure occurred, or HEY kept a
message as a draft instead of sending it (not_delivered).
8 The request matched more than one resource.
9 Pending: the HEY app took the request and waits in its window for you to
confirm it (hey local send, or hey local call of a send). Nothing has
happened yet.

JSON failures also carry a machine-readable error code and an actionable hint when one is available.`,
},
Expand All @@ -59,6 +62,7 @@ CONNECTION & AUTHENTICATION
HEY_MCP_CLIENT_ID Override the HEY MCP (local) client id hey auth login --mcp signs in as.
HEY_BASE_URL Override the HEY server URL.
HEY_ACCOUNT_ID Select a linked mail account ID or all.
HEY_LOCAL_SOCKET The HEY app's socket for hey local, in place of $XDG_RUNTIME_DIR/hey/agent.sock.
HEY_NO_KEYRING Store credentials in the config directory instead of a keyring.

INTERACTION & DIAGNOSTICS
Expand Down
Loading
Loading