hey local: ask the HEY app on this computer to act for you
Closed
JE
1d ago+3748+3748+0123456789012345678901234567890123456789-3-3-0123456789
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-localcapability probeshey local --help, so today every task that needs it is skipped.
Change
hey local toolslists what the app offers now, with each command's effect (read,reversibleorreaches_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=42is a number,decision=outa word).- Sugar for the first commands:
hey local imbox,hey local screener list|approve|deny <sender>,hey local send <draft>.denysays 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 -morhey local reply <thread> -m(a thread fromhey 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 draftslists the app's drafts, which HEY would send now and which are scheduled. The message is plain text from-mor stdin. - The door's refusals read plainly, with their exit codes:
not_an_addressnames the pieces of--to(1);not_savedsays which limit, 1 where the caller's words or--topassed it and 7 where a reply's thread did;not_a_sendernames 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 withmeta.status: "pending", the confirmation id and the snapshot;--ids-onlyprints the confirmation id and--countnothing, both with the words on stderr. Every command checks for pending, not onlysend, 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 ismain's too: govulncheck's GO-2026-6859 innet/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, elseHEY_LOCAL_SOCKET, else$XDG_RUNTIME_DIR/hey/agent.sock. With no absoluteXDG_RUNTIME_DIRit asks for--socketrather than guess, as the door's contract asks. hey localnever 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;initializeandnotifications/initializedfirst, thentools/list(followingnextCursor) andtools/call; it answers a ping and passes over notifications.internal/localapp/localapptestis 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 fromhey commandsandhey 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
--argover 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 noHEY_TOKEN. - A fresh-context review (round 1) found 1 High (pending under
--ids-only/--countexited 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 as1.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 (--markdownprinting 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, nothey upgrade, and keeps a plain-text answer. Copilot's pass on a67ec14 found listings reading a missing field as a fact (a decided sender withoutscreened_inshown as screened out, nohas_moreas 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 sendrefusing an answer that isn't pending, the undo hint keeping--socket, andscreener list/imbox--ids-onlyand--countwith 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-onlyon a saved draft failed with exit 1, inviting a retry that writes a second) and 5 Lows, all fixed in 887c69f, red first:--ids-onlynow prints a saved draft's id;unavailablefrom write or reply, which the app also answers for a draft saved then gone before HEY listed it, says to checkhey local drafts;--tois required. Round 2 over those fixes: no High or Medium; its stderr warning for a draft HEY won't send under--ids-onlytaken 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,
TestLocalSendWaitingInHEYIsPendingNotSentNorFailedandTestLocalCallOfASendIsPendingToofailed witherr = <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 checkon 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 -raceoverinternal/cmd,internal/localappandinternal/output, golangci-lint 0 issues,check-surfaceok;go test -race -count=2overinternal/localapp,internal/outputand theTestLocaltests passes;GOOS=windowsandGOOS=darwinbuilds 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/discoverbeforeinitializeand 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. --argtyped by each tool's JSON Schema: it would cost atools/listper 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 localthat 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.replyreaches 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 localcounts only once this is merged and a release is cut.