hey local: ask the HEY app on this computer to act for you

Closed
JE
1d ago
+3748-3

Context

  • The HEY desktop app is opening a door for agents: while "Let agents use HEY" is on in its Settings, it serves its own commands as MCP over a Unix socket, and a send waits in its window until the person presses Send. The app's side is being built in hey-native-apps (the door on Linux, branch wt/localmode-door; the confirmation sheet, #11223).
  • Nothing in hey-cli spoke to it. The agent eval's hey-local capability probes hey local --help, so today every task that needs it is skipped.

Change

  • hey local tools lists what the app offers now, with each command's effect (read, reversible or reaches_people) and its arguments.
  • hey local call <command> [json] [--arg name=value] runs any of them. A value is read as JSON where it is JSON (sender=42 is a number, decision=out a word).
  • Sugar for the first commands: hey local imbox, hey local screener list|approve|deny <sender>, hey local send <draft>. deny says how to undo it (hey local screener approve <sender>), in its line and its breadcrumb.
  • An agent can carry a message end to end (the door's drafts, hey-native-apps#11400): hey local write --to --subject -m or hey local reply <thread> -m (a thread from hey local imbox's Thread column) saves a draft, sends nothing, and answers its id with the command that asks HEY to send it; hey local send <id> then waits for the person to press Send in HEY (exit 9). hey local drafts lists the app's drafts, which HEY would send now and which are scheduled. The message is plain text from -m or stdin.
  • The door's refusals read plainly, with their exit codes: not_an_address names the pieces of --to (1); not_saved says which limit, 1 where the caller's words or --to passed it and 7 where a reply's thread did; not_a_sender names the address the account lost (7); a reply's unreadable thread (7); a send of a draft HEY won't send now (1, with the drafts it would); session_ended, a call cut off by a sign-out or account switch, says it may have been done and to check HEY (7), never "refused".
  • A call the app answers pending (a send waiting in HEY) prints "Waiting in HEY ... Nothing has been sent", with To and Subject, and exits 9, a new code in hey help exit-codes: neither done nor failed. JSON gets the envelope with meta.status: "pending", the confirmation id and the snapshot; --ids-only prints the confirmation id and --count nothing, both with the words on stderr. Every command checks for pending, not only send, and the sugar refuses an answer that isn't the shape it shows. Nothing here can confirm a send, and there is no --yes.
  • No socket, or a stale one nothing answers on: exit 6, "Turn on "Let agents use HEY" in the HEY app's Settings (General)". A refusal carries the app's reason in meta.refusal: exit 1 where it names an argument (the hint lists what it takes, e.g. sender takes one of: 51, 52) or for an unknown command, 3 for the app signed out of HEY, 7 otherwise. A connection the app closes unanswered (another user account) exits 6 and says so. Security's red is main's too: govulncheck's GO-2026-6859 in net/http, fixed in Go 1.27.4, while the repository pins 1.27.3; nothing here calls it, and the toolchain bump is its own pull request.
  • Socket: --socket, else HEY_LOCAL_SOCKET, else $XDG_RUNTIME_DIR/hey/agent.sock. With no absolute XDG_RUNTIME_DIR it asks for --socket rather than guess, as the door's contract asks.
  • hey local never reaches HEY's servers and needs no sign-in: it skips the credential migration, the account scope and the local config trust gate.
  • The client is internal/localapp: plain MCP written out, JSON-RPC 2.0, one message per line; initialize and notifications/initialized first, then tools/list (following nextCursor) and tools/call; it answers a ping and passes over notifications. internal/localapp/localapptest is a fake door for tests.
  • Docs: docs/agents.md (new section, effects table), docs/cli.md (pointer), hey help exit-codes, hey help environment, .surface. Not in root help, on purpose: it is reachable from hey commands and hey local --help, and shows nothing until the app ships the door.

Verification

  • Go tests against the fake door, in the client (20) and the commands (49): list, call, typed arguments, a JSON object with --arg over it, pending, a refusal never pending, no socket, a stale socket, no runtime folder, the flag over the environment, a JSON-RPC error, malformed replies (not JSON, not 2.0, another request's id, no result, the wrong shape), an Imbox in another shape, a ping and a notification before the answer, a timeout, signed out of the app. Every command test fails if anything reaches HEY's server, and runs with no HEY_TOKEN.
  • A fresh-context review (round 1) found 1 High (pending under --ids-only/--count exited 1), 3 Medium (wrong refusal mapping, sugar not checking for pending or for the answer's shape) and 4 Low; all but one fixed in the second commit (the one declined: root help, above), each with a test shown red against the first commit's code (10 cases across 7 tests). Round 2 reviewed the fixes alone: 1 Medium (a refused id over a million hinted as 1.731280000123e+12, and the refusal is the one place an agent learns draft ids) and 2 Lows, fixed in aa0698d, red first; a third Low (--markdown printing raw JSON as bytes) did not reproduce. Round 3 reviewed that fix: no High or Medium; its 2 Lows (an empty list of cases, an unmapped write error after initialize) fixed in 5239129. Then Copilot, cubic and Greptile reviewed 5239129: 34 threads, 32 fixed in 828511c (12 new tests, each red against 5239129's code first) and 2 declined in their threads. cubic's next passes found 3 more, fixed in e27c34f and a67ec14: a send the app answers otherwise now points at the app, not hey upgrade, and keeps a plain-text answer. Copilot's pass on a67ec14 found listings reading a missing field as a fact (a decided sender without screened_in shown as screened out, no has_more as a complete Imbox): a111a60 requires every field whose zero value would say something, as the contract has the app always give it. Among the fixes: clientInfo.version, each call bound by its own context, hey local send refusing an answer that isn't pending, the undo hint keeping --socket, and screener list / imbox --ids-only and --count with their notices.
  • The drafts commands' tests (drafts, write, reply, a reply answered on another thread, write then send waiting in HEY, write with no message, eight refusal cases, three draft-shape cases) and session_ended's each failed first against the commands as they were, with a type-only stub so they compiled. A fresh-context review of them found 1 Medium (--ids-only on a saved draft failed with exit 1, inviting a retry that writes a second) and 5 Lows, all fixed in 887c69f, red first: --ids-only now prints a saved draft's id; unavailable from write or reply, which the app also answers for a draft saved then gone before HEY listed it, says to check hey local drafts; --to is required. Round 2 over those fixes: no High or Medium; its stderr warning for a draft HEY won't send under --ids-only taken in 530498f. cubic's three threads on 3e90dad (a saved draft's kind, the app's refusal detail, the reply docs) fixed in fcf918b, red first.
  • Shown red first: with the pending branch unwired, TestLocalSendWaitingInHEYIsPendingNotSentNorFailed and TestLocalCallOfASendIsPendingToo failed with err = <nil> (exit 1): the send read as success. Mutations that dropped the refusal check, the answer-id check or ECONNREFUSED each turned a client test red.
  • make check on Linux (thelio) at fcf918b: exit 0, 123 packages, lint 0 issues; at 828511c the same; at a111a60, at a quarter width for thelio's load: go test ./..., go test -race over internal/cmd, internal/localapp and internal/output, golangci-lint 0 issues, check-surface ok; go test -race -count=2 over internal/localapp, internal/output and the TestLocal tests passes; GOOS=windows and GOOS=darwin builds compile. Linux caught two things macOS did not: a door that closes on a caller reaches the client as a reset (fixed in 0882207), and a test server that closed too early (aa0698d).
  • Checked against the door's contract, settled 2026-11-10 by the app lane: socket path, framing, initialize / tools/list / tools/call, the seven tool names and argument types (with fa0b3260966's drafts), the Done, Pending and Refused shapes, _meta["com.hey/effect"].
  • Not run against the real app: its door isn't built yet. The first real run is the eval's proof run.

Alternatives

  • The go-sdk's MCP client, already a dependency: its newest protocol tries server/discover before initialize and negotiates versions, more than three methods on a local socket need, and a malformed line is the SDK's to handle, not ours to report plainly.
  • --arg typed by each tool's JSON Schema: it would cost a tools/list per call. A word that would read as a number is quoted (--arg 'subject="2026"').
  • Verifying the app's uid from the client (SO_PEERCRED): the default socket sits in the person's own 0700 XDG_RUNTIME_DIR, which another account can't write into; the door checks the caller's uid on its side.
  • macOS is not built. The Mac app needs each agent paired in the app and the caller's code signature checked first, since there a socket would let any program skip the Keychain prompt that guards HEY's sign-in. For hey local that would be the same client over the Mac's socket path plus whatever pairing the app asks for, likely a token it issues; nothing to build until that exists.

Follow-ups

  • No command edits or deletes a draft (the door offers none): an agent that wrote the wrong one writes another, and the person deletes the first in HEY. draft.reply reaches only Imbox threads.
  • The eval's server-side checks call hey box view sent|spam --all --json, which hey-cli doesn't list yet: a separate pull request.
  • The eval needs a released build to score the customer role: the eval machine runs a released hey-cli, so hey local counts only once this is merged and a release is cut.