hey-cli
AGENTS.md423 starsChanged 8 months agoMITView on GitHub
This file provides guidance to AI coding agents working with this repository. hey-cli is a CLI and TUI interface for HEY. It allows users to read and send emails, manage their boxes, manage their calendars and journal entries. The TUI is primarily intended for human use, while the CLI is primarily intended for use by AI agents and for scripting. This project uses make. make help covers most targets -- though not all of them: install, check-toolchain and help itself…
- Reads credentials
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,700 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository3 other files it gives its agents.
- docs/agents.mdAGENTS.md
- .claude/CLAUDE.mdCLAUDE.md
- heySkill
The file
Show its rules and text
We split this AGENTS.md into the rules it gives an agent
18 rules. About 0 are common, 4 are distinctive, and 14 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
14
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 34
All API interactions go through the HEY SDK (hey-sdk/go), with typed service methods accessed via internal/cmd/sdk.go (e.g., …
- Very specific: line 43
Every data-access command calls requireAuth() before it makes an API call.
- Very specific: line 83
Every command that writes something confirms it through writeMutation (or writeMutationLine, where the line carries an id or a name the …
- Very specific: line 157
Delete a repeating day through its occurrence.
- Distinctive: line 182
If you are unsure what the API endpoints are, what they expect or what they respond to you can read through the server implementation to …
- Distinctive: line 187
If you don't understand how the routes are laid out you can call rails routes in that directory to get a list of all the routes and their …
- Very specific: line 191
All API interactions must go through the HEY SDK (hey-sdk/go).
- Very specific: line 193
If you need to call an endpoint that the SDK doesn't support yet, add it to the SDK.
- Very specific: line 197
Add a replace directive in go.mod pointing at your checkout.
- Very specific: line 206
Call out that you made changes to the SDK: when you're done — your operator will review those changes and publish a new SDK release, then …
- Distinctive: line 216
When you add any kind of example make it realistic.
- Distinctive: line 221
Never use abbreviations or placeholders like "Test Event", "User1", "a@ex.com".
- Very specific: line 244
All CLI invocations run in an isolated environment: temp XDG_CONFIG_HOME, HEY_NO_KEYRING=1, HEY_BASE_URL pointing to the dev server.
- Very specific: line 247
Test data uses uniqueID() (nanosecond timestamps) to avoid collisions.
- Very specific: line 252
Create or edit a *_test.go file in tests/smoke/ (package smoke_test).
- Very specific: line 253
Use heyJSON(t, "command", "args...") for commands that should succeed and return JSON.
- Very specific: line 254
Use heyFail(t, "command", "args...") for commands that should fail.
- Very specific: line 256
Use dataAsT to unmarshal response data into typed structs.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
hey-cli
This file provides guidance to AI coding agents working with this repository.
What is hey-cli?
hey-cli is a CLI and TUI interface for HEY. It allows users to read and send emails, manage their boxes, manage their calendars and journal entries. The TUI is primarily intended for human use, while the CLI is primarily intended for use by AI agents and for scripting.
Development commands
This project uses make. make help covers most targets -- though not all of them:
install, check-toolchain and help itself are defined but unlisted.
make build # Builds ./bin/hey
make test # Runs the tests
make lint # Lints the code
make install # Installs the binary to /usr/local/bin/hey (not shown by make help)Git
History is linear: merge commits are disallowed in this repository. Bring a branch up to date by rebasing it onto main, never by merging main into it.
Architecture Overview
This is a Go project that uses:
- spf13/cobra for the CLI interface
- [charm.land/bubbletea/v2] for the TUI interface along with bubbles/v2 and lipgloss/v2 (these are new versions that recently came out and differ from the v1 versions!)
All API interactions go through the HEY SDK (hey-sdk/go), with typed service methods accessed via internal/cmd/sdk.go (e.g., sdk.Boxes().List, sdk.Messages().Create, sdk.Calendars().GetRecordings). Authentication and token refresh are handled via internal/auth/.
Authentication
internal/auth/ manages four methods: browser OAuth with PKCE (hey auth login, the
primary one), a stored bearer token (--token), a browser session cookie (--cookie), and
the HEY_TOKEN environment variable. The Manager refreshes tokens with a 5-minute buffer,
and the SDK reaches it through the bridge in internal/cmd/sdk.go.
Every data-access command calls requireAuth() before it makes an API call. Logged out,
it exits 3 with Run: hey auth login, or offers to sign in at an interactive terminal with
styled output. HEY_NONINTERACTIVE=1 turns the offer off. The auth subcommands, hey
login/hey logout, hey setup and hey doctor work without authentication.
Coding-agent setup lives in internal/harness and internal/cmd/setup_agent*.go. Every
skill write goes through claimSkillDir, which marks a directory it creates
(.managed-by-hey-cli) and refuses a populated one without the marker. Do not add a write
path that bypasses the gate.
The setup wizard, the Omarchy bar plugin and the skill refresh hook are in docs/architecture/auth-and-setup.md.
State storage
Configuration (the base URL, the default linked account, the Imbox's cover, and the onboarded flag) is stored in ~/.config/hey-cli/config.json; onboarded is read from the global file only, never from a repository's .hey/config.json. Credentials are stored in the system keyring (service name: hey) with automatic fallback to ~/.config/hey-cli/credentials.json when the keyring is unavailable. Set HEY_NO_KEYRING=1 to force file storage.
CLI
The README is the front door, not the manual: install, first run, a dozen examples, and
links. Keep it that way. When you change, add or remove a command, the behaviour goes in
the command's --help text and its section of docs/cli.md (docs/tui.md for the TUI,
docs/agents.md for the skill and MCP, docs/install.md for installing and upgrading).
Touch the README only if one of its examples no longer runs or a command it names is gone.
New top-level commands and subcommands need the .surface snapshot updated
(go test ./internal/cmd -run TestSurfaceSnapshot -update-surface), and anything listed in
root help needs the byte-exact literal in help_test.go updated.
Nothing scrapes a web page
HEY grew JSON for every endpoint the CLI reads,
the SDK dropped its own scrapers, and the last three scrapes here are gone. Do not answer
"the JSON looks incomplete" by parsing a web page — check whether the SDK already has a
typed operation, and if HEY does not serve JSON for what you need, add it to the SDK
rather than working around it here. sdk.GetHTML has no callers left; if you find yourself
reaching for it, that is the signal to stop.
One line for a person, the envelope for everyone else
Every command that writes something confirms it through writeMutation (or
writeMutationLine, where the line carries an id or a name the summary does not).
It prints the sanitized line under --styled and hands writeOK the data and the
summary otherwise. Do not hand-roll the if writer.IsStyled() branch again: it was
copy-pasted about forty times, and three of the copies had drifted.
TUI structure
The TUI uses the sectionView interface pattern. Each top-level section (Mail, Contacts, Calendar, Journal) implements sectionView and owns its data, fetch commands, key handling, rendering, and help bindings. The main model delegates to the active view.
Four files implement sectionView -- mail.go, contacts.go, calendar.go and
journal.go, one per section. screener.go implements it too, but The Screener is not a
section: ctrl+s from the mail list swaps it in as the active view, it captures every key
while it is open, and it asks to be closed again with screenerClosedMsg.
The rest of internal/tui/ is shared infrastructure, starting with tui.go (model and
router) and section_view.go (the interface).
To add a new section: implement the sectionView interface in a new file, add a field and constructor call in newModel, and add a case in switchSection.
Subsystems
Each subsystem below has a design doc in docs/architecture/. The rules here are the ones
a change is most likely to break. Read the doc before you change the subsystem.
Threads and replies
Topics.Get carries no bodies. internal/threadload reads them, within literal limits,
and reports per entry whether each body was read. A reply starts from
mail.ReplyPrefillFromServer and answers mail.ReplyTarget. Extend those two rather than
reimplement HEY's rules in a caller. Compose forms filter recipient suggestions in memory,
so a keystroke never waits on the network.
Details: docs/architecture/threads-and-replies.md.
Terminal safety
Nothing HEY serves reaches a terminal raw. Metadata goes through
terminal.Sanitize/SanitizeLine. A body is an htmlutil.Markdown, which only
htmlutil.ToMarkdown makes and only markdown.Render accepts. JSON goes through
output.MarshalJSON. TestSinksAreSanitized catches a direct write of a listed field to a
listed sink, and a new exemption in its manifest needs a reason. sdkContactToModel and
sdkRecordingToModel are deliberately left unsanitized, because their fields go back
through edit forms. Sanitize where they are shown.
Details: docs/architecture/terminal-safety.md.
Email bodies
htmlutil.ToMarkdown converts a body once, at the edge, and everything downstream carries
Markdown. Entry.BodyHTML keeps the original HTML for --html. An inbound HTML email
hides its whole body in a Trix attachment with no filename. Do not treat that as a file.
Details: docs/architecture/email-bodies.md.
Mail sources and paging
mail.ReadPage is the one read for a box, a label, a collection, a bundle and a contact's
threads. A source knows its kind from BoxKind, never from its name. Page.Cursor is
opaque and differs by kind. A box pages by HEY's next_history_url on its own route,
dispatched on BoxKind. A label and a collection page by the geared_pagination cursor in
the Link header, which only the SDK's *Page methods keep. Search and contacts number
their pages instead. A live
re-read splices the top page in through refreshHead.
Details: docs/architecture/mail-sources-and-paging.md.
Watching for changes
Both hey watch and the TUI take an Action Cable broadcast as a doorbell, not as the
change. hey watch then reads the change from the posting sync feed, whose cursor is the
source of truth, and its start, resync and "new mail" rules are subtle. The TUI just
re-reads the box's top page, and re-reads the box on screen after a reconnect. Read the
doc before you change watch*.go or tui_watch.go.
Details: docs/architecture/watch.md.
Calendar and time tracking
A selection is a Recording.key(), not an id, because HEY serves a repeating event's virtual days with id 0.
Delete a repeating day through its occurrence. Tracked time is readable only through
TimeTracks().ListPage. An event update is not a partial, so send every field back.
Details: docs/architecture/calendar.md.
Imbox cover art
Covers use the ANSI-16 slots, never hex, with one palette per preset. Only the Imbox is
coverable, as mail.Source.Coverable() decides. The choice is stored locally on purpose.
Details: docs/architecture/cover-art.md.
Inline images
The TUI draws images with Kitty's Unicode placeholders. Uploads are PNG, and image ids
come from nextImageID and are never reused.
Details: docs/architecture/inline-images.md.
Upgrade command
hey upgrade hands Homebrew and Scoop installs to their package manager. On its own path
it trusts nothing it downloaded until sigstore verifies the release's checksums.
Its tests go through seam variables and never talk to GitHub or run a package manager.
Details: docs/architecture/upgrade.md.
API documentation
If you are unsure what the API endpoints are, what they expect or what they respond to you can read through the server implementation to understand how the API works.
The server is the basecamp/haystack repo. If you have it checked out (conventionally
~/Work/basecamp/haystack/), read it whenever you need to know how the API behaves.
If you don't understand how the routes are laid out you can call rails routes in that directory to get a list of all the routes and their corresponding controller actions.
SDK
All API interactions must go through the HEY SDK (hey-sdk/go). There is no legacy client — the SDK is the only HTTP client.
If you need to call an endpoint that the SDK doesn't support yet, add it to the SDK.
The SDK is the basecamp/hey-sdk repo, conventionally checked out at
~/Work/basecamp/hey-sdk. To use your local changes:
Add a
replacedirective ingo.modpointing at your checkout. Give it a real path. The shell does not expand~in the middle of an argument, so...hey-sdk/go=~/Work/...reaches Go as a literal tilde and the command fails withunversioned new path must be local directory:go mod edit -replace github.com/basecamp/hey-sdk/go="$HOME/Work/basecamp/hey-sdk/go"- Implement and test your changes
- Call out that you made changes to the SDK when you're done — your operator will review those changes and publish a new SDK release, then you can remove the
replacedirective and pin to the released version
Those paths are in the SDK repo, not this one. There, the hand-written service wrappers
in go/pkg/hey/ (e.g. messages.go, journal.go, postings.go) are safe to edit
directly. The Smithy-generated code in go/pkg/generated/ is not — update the Smithy
model and run make smithy-build then make go-generate from the SDK root. hey-cli has
neither target.
Examples
When you add any kind of example make it realistic.
For emails, always use @example.com or @example.org domains to avoid accidentally sending emails to real people. For names, use common names or fictional characters. For calendar events, use plausible titles and times. The goal is to make the examples feel authentic without risking privacy or confusion. Never use abbreviations or placeholders like "Test Event", "User1", "a@ex.com". Instead, use full names and descriptive titles that reflect real-world usage.
Unit Testing
Whenever you add, remove or change any functionality add/remove/change tests as well. Tests are located in the same package as the code they test, with filenames ending in _test.go. Run make test to run all tests.
Smoke Testing
Smoke tests verify all CLI commands against a real HEY server. They live in tests/smoke/ as a separate Go module and use a pre-compiled binary built by make build.
What they test: Every CLI command and its flags — boxes, box, compose, reply, threads, drafts, calendars, recordings, todo, journal, habit, timetrack, seen/unseen, config, auth, and all output format flags (--json, --quiet, --ids-only, --count, --markdown, --styled, --verbose, --stats). Browser-based cross-verification tests confirm CLI actions are visible in the browser and vice versa.
Running:
make test-smoke # Builds binary then runs tests (requires dev server)The dev server must be running at http://app.hey.localhost:3003 (override with HEY_SMOKE_BASE_URL). Default login: david@basecamp.com / secret123456 (override with HEY_SMOKE_EMAIL and HEY_SMOKE_PASSWORD).
How they work:
TestMaininhelpers_test.goorchestrates setup: finds the binary, checks server reachability, launches headless Chrome via chromedp to log in and extract thesession_tokencookie, then authenticates the CLI withhey auth login --cookie.- All CLI invocations run in an isolated environment: temp
XDG_CONFIG_HOME,HEY_NO_KEYRING=1,HEY_BASE_URLpointing to the dev server. - Helper functions (
hey(),heyOK(),heyJSON(),heyFail()) run the binary and parse output.dataAs[T]()generically unmarshals response data. - Tests that depend on write operations (compose, todo add, journal write, reply, timetrack start) skip gracefully when the server returns errors, since the SDK's parameter format may not match the server's expectations. Set
HEY_SMOKE_STRICT=1to turn every such skip into a failure — that is the release gate, where a skipped check is not a passed one. - Test data uses
uniqueID()(nanosecond timestamps) to avoid collisions. Cleanup happens viat.Cleanup(). hey upgradeis covered only as far as a dev build'supgrade_requiredrefusal (andhey version --json). The live self-update against a real release runs in.github/workflows/upgrade-smoke.yml, never against the dev server.
How to add a new test:
- Create or edit a
*_test.gofile intests/smoke/(packagesmoke_test). - Use
heyJSON(t, "command", "args...")for commands that should succeed and return JSON. - Use
heyFail(t, "command", "args...")for commands that should fail. - For write operations that may fail server-side, use
hey(t, ...)directly and skip on non-zero exit through the strict-aware helper:if code != 0 { skipf(t, "... (exit %d): %s", code, stderr) }. Never callt.Skipdirectly —skipfis what turns a skip into a failure underHEY_SMOKE_STRICT=1. - Use
dataAs[T](t, resp)to unmarshal response data into typed structs. - For browser cross-verification, use
browserPageText(t, url)to get page content.
Running
To run the CLI use make build and then ./bin/hey. This ensures that you and I are running the same version of the program.
Code style
See STYLE.md for the Go conventions used here. Read it when writing or reviewing Go in
this repo -- it is not imported, so it does not sit in context for sessions that never
touch Go.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

