diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go
new file mode 100644
index 00000000..1fc6568d
--- /dev/null
+++ b/internal/cmd/mail.go
@@ -0,0 +1,285 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "github.com/steveyegge/gastown/internal/mail"
+ "github.com/steveyegge/gastown/internal/style"
+ "github.com/steveyegge/gastown/internal/workspace"
+)
+
+// Mail command flags
+var (
+ mailSubject string
+ mailBody string
+ mailPriority string
+ mailNotify bool
+ mailInboxJSON bool
+ mailReadJSON bool
+ mailInboxUnread bool
+)
+
+var mailCmd = &cobra.Command{
+ Use: "mail",
+ Short: "Agent messaging system",
+ Long: `Send and receive messages between agents.
+
+The mail system allows Mayor, polecats, and the Refinery to communicate.`,
+}
+
+var mailSendCmd = &cobra.Command{
+ Use: "send
",
+ Short: "Send a message",
+ Long: `Send a message to an agent.
+
+Addresses:
+ mayor/ - Send to Mayor
+ /refinery - Send to a rig's Refinery
+ / - Send to a specific polecat
+ / - Broadcast to a rig
+
+Examples:
+ gt mail send gastown/Toast -s "Status check" -m "How's that bug fix going?"
+ gt mail send mayor/ -s "Work complete" -m "Finished gt-abc"
+ gt mail send gastown/ -s "All hands" -m "Swarm starting" --notify`,
+ Args: cobra.ExactArgs(1),
+ RunE: runMailSend,
+}
+
+var mailInboxCmd = &cobra.Command{
+ Use: "inbox [address]",
+ Short: "Check inbox",
+ Long: `Check messages in an inbox.
+
+If no address is specified, shows the current context's inbox.
+
+Examples:
+ gt mail inbox # Current context
+ gt mail inbox mayor/ # Mayor's inbox
+ gt mail inbox gastown/Toast # Polecat's inbox`,
+ Args: cobra.MaximumNArgs(1),
+ RunE: runMailInbox,
+}
+
+var mailReadCmd = &cobra.Command{
+ Use: "read ",
+ Short: "Read a message",
+ Long: `Read a specific message and mark it as read.
+
+The message ID can be found from 'gt mail inbox'.`,
+ Args: cobra.ExactArgs(1),
+ RunE: runMailRead,
+}
+
+func init() {
+ // Send flags
+ mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
+ mailSendCmd.Flags().StringVarP(&mailBody, "message", "m", "", "Message body")
+ mailSendCmd.Flags().StringVar(&mailPriority, "priority", "normal", "Message priority (normal, high)")
+ mailSendCmd.Flags().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient")
+ mailSendCmd.MarkFlagRequired("subject")
+
+ // Inbox flags
+ mailInboxCmd.Flags().BoolVar(&mailInboxJSON, "json", false, "Output as JSON")
+ mailInboxCmd.Flags().BoolVarP(&mailInboxUnread, "unread", "u", false, "Show only unread messages")
+
+ // Read flags
+ mailReadCmd.Flags().BoolVar(&mailReadJSON, "json", false, "Output as JSON")
+
+ // Add subcommands
+ mailCmd.AddCommand(mailSendCmd)
+ mailCmd.AddCommand(mailInboxCmd)
+ mailCmd.AddCommand(mailReadCmd)
+
+ rootCmd.AddCommand(mailCmd)
+}
+
+func runMailSend(cmd *cobra.Command, args []string) error {
+ to := args[0]
+
+ townRoot, err := workspace.FindFromCwdOrError()
+ if err != nil {
+ return fmt.Errorf("not in a Gas Town workspace: %w", err)
+ }
+
+ // Determine sender
+ from := detectSender(townRoot)
+
+ // Create message
+ msg := mail.NewMessage(from, to, mailSubject, mailBody)
+
+ // Set priority
+ if mailPriority == "high" || mailNotify {
+ msg.Priority = mail.PriorityHigh
+ }
+
+ // Send
+ router := mail.NewRouter(townRoot)
+ if err := router.Send(msg); err != nil {
+ return fmt.Errorf("sending message: %w", err)
+ }
+
+ fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to)
+ fmt.Printf(" ID: %s\n", style.Dim.Render(msg.ID))
+ fmt.Printf(" Subject: %s\n", mailSubject)
+
+ return nil
+}
+
+func runMailInbox(cmd *cobra.Command, args []string) error {
+ townRoot, err := workspace.FindFromCwdOrError()
+ if err != nil {
+ return fmt.Errorf("not in a Gas Town workspace: %w", err)
+ }
+
+ // Determine which inbox to check
+ address := ""
+ if len(args) > 0 {
+ address = args[0]
+ } else {
+ address = detectSender(townRoot)
+ }
+
+ // Get mailbox
+ router := mail.NewRouter(townRoot)
+ mailbox, err := router.GetMailbox(address)
+ if err != nil {
+ return fmt.Errorf("getting mailbox: %w", err)
+ }
+
+ // Get messages
+ var messages []*mail.Message
+ if mailInboxUnread {
+ messages, err = mailbox.ListUnread()
+ } else {
+ messages, err = mailbox.List()
+ }
+ if err != nil {
+ return fmt.Errorf("listing messages: %w", err)
+ }
+
+ // JSON output
+ if mailInboxJSON {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(messages)
+ }
+
+ // Human-readable output
+ total, unread, _ := mailbox.Count()
+ fmt.Printf("%s Inbox: %s (%d messages, %d unread)\n\n",
+ style.Bold.Render("📬"), address, total, unread)
+
+ if len(messages) == 0 {
+ fmt.Printf(" %s\n", style.Dim.Render("(no messages)"))
+ return nil
+ }
+
+ for _, msg := range messages {
+ readMarker := "●"
+ if msg.Read {
+ readMarker = "○"
+ }
+ priorityMarker := ""
+ if msg.Priority == mail.PriorityHigh {
+ priorityMarker = " " + style.Bold.Render("!")
+ }
+
+ fmt.Printf(" %s %s%s\n", readMarker, msg.Subject, priorityMarker)
+ fmt.Printf(" %s from %s\n",
+ style.Dim.Render(msg.ID),
+ msg.From)
+ fmt.Printf(" %s\n",
+ style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04")))
+ }
+
+ return nil
+}
+
+func runMailRead(cmd *cobra.Command, args []string) error {
+ msgID := args[0]
+
+ townRoot, err := workspace.FindFromCwdOrError()
+ if err != nil {
+ return fmt.Errorf("not in a Gas Town workspace: %w", err)
+ }
+
+ // Determine which inbox
+ address := detectSender(townRoot)
+
+ // Get mailbox and message
+ router := mail.NewRouter(townRoot)
+ mailbox, err := router.GetMailbox(address)
+ if err != nil {
+ return fmt.Errorf("getting mailbox: %w", err)
+ }
+
+ msg, err := mailbox.Get(msgID)
+ if err != nil {
+ return fmt.Errorf("getting message: %w", err)
+ }
+
+ // Mark as read
+ mailbox.MarkRead(msgID)
+
+ // JSON output
+ if mailReadJSON {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(msg)
+ }
+
+ // Human-readable output
+ priorityStr := ""
+ if msg.Priority == mail.PriorityHigh {
+ priorityStr = " " + style.Bold.Render("[HIGH PRIORITY]")
+ }
+
+ fmt.Printf("%s %s%s\n\n", style.Bold.Render("Subject:"), msg.Subject, priorityStr)
+ fmt.Printf("From: %s\n", msg.From)
+ fmt.Printf("To: %s\n", msg.To)
+ fmt.Printf("Date: %s\n", msg.Timestamp.Format("2006-01-02 15:04:05"))
+ fmt.Printf("ID: %s\n", style.Dim.Render(msg.ID))
+
+ if msg.Body != "" {
+ fmt.Printf("\n%s\n", msg.Body)
+ }
+
+ return nil
+}
+
+// detectSender determines the current context's address.
+func detectSender(townRoot string) string {
+ // Check environment variables (set by session start)
+ rig := os.Getenv("GT_RIG")
+ polecat := os.Getenv("GT_POLECAT")
+
+ if rig != "" && polecat != "" {
+ return fmt.Sprintf("%s/%s", rig, polecat)
+ }
+
+ // Check current directory
+ cwd, err := os.Getwd()
+ if err != nil {
+ return "mayor/"
+ }
+
+ // If in a rig's polecats directory, extract address
+ if strings.Contains(cwd, "/polecats/") {
+ parts := strings.Split(cwd, "/polecats/")
+ if len(parts) >= 2 {
+ rigPath := parts[0]
+ polecatPath := strings.Split(parts[1], "/")[0]
+ rigName := filepath.Base(rigPath)
+ return fmt.Sprintf("%s/%s", rigName, polecatPath)
+ }
+ }
+
+ // Default to mayor
+ return "mayor/"
+}
diff --git a/internal/mail/mailbox.go b/internal/mail/mailbox.go
new file mode 100644
index 00000000..ec4f91ac
--- /dev/null
+++ b/internal/mail/mailbox.go
@@ -0,0 +1,222 @@
+package mail
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "sort"
+)
+
+// Common errors
+var (
+ ErrMessageNotFound = errors.New("message not found")
+ ErrEmptyInbox = errors.New("inbox is empty")
+)
+
+// Mailbox manages a JSONL-based inbox.
+type Mailbox struct {
+ path string
+}
+
+// NewMailbox creates a mailbox at the given path.
+func NewMailbox(path string) *Mailbox {
+ return &Mailbox{path: path}
+}
+
+// Path returns the mailbox file path.
+func (m *Mailbox) Path() string {
+ return m.path
+}
+
+// List returns all messages in the mailbox.
+func (m *Mailbox) List() ([]*Message, error) {
+ file, err := os.Open(m.path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ defer file.Close()
+
+ var messages []*Message
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if line == "" {
+ continue
+ }
+
+ var msg Message
+ if err := json.Unmarshal([]byte(line), &msg); err != nil {
+ continue // Skip malformed lines
+ }
+ messages = append(messages, &msg)
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ // Sort by timestamp (newest first)
+ sort.Slice(messages, func(i, j int) bool {
+ return messages[i].Timestamp.After(messages[j].Timestamp)
+ })
+
+ return messages, nil
+}
+
+// ListUnread returns unread messages.
+func (m *Mailbox) ListUnread() ([]*Message, error) {
+ all, err := m.List()
+ if err != nil {
+ return nil, err
+ }
+
+ var unread []*Message
+ for _, msg := range all {
+ if !msg.Read {
+ unread = append(unread, msg)
+ }
+ }
+
+ return unread, nil
+}
+
+// Get returns a message by ID.
+func (m *Mailbox) Get(id string) (*Message, error) {
+ messages, err := m.List()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, msg := range messages {
+ if msg.ID == id {
+ return msg, nil
+ }
+ }
+
+ return nil, ErrMessageNotFound
+}
+
+// Append adds a message to the mailbox.
+func (m *Mailbox) Append(msg *Message) error {
+ // Ensure directory exists
+ dir := filepath.Dir(m.path)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return err
+ }
+
+ // Open for append
+ file, err := os.OpenFile(m.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ data, err := json.Marshal(msg)
+ if err != nil {
+ return err
+ }
+
+ _, err = file.WriteString(string(data) + "\n")
+ return err
+}
+
+// MarkRead marks a message as read.
+func (m *Mailbox) MarkRead(id string) error {
+ messages, err := m.List()
+ if err != nil {
+ return err
+ }
+
+ found := false
+ for _, msg := range messages {
+ if msg.ID == id {
+ msg.Read = true
+ found = true
+ }
+ }
+
+ if !found {
+ return ErrMessageNotFound
+ }
+
+ return m.rewrite(messages)
+}
+
+// Delete removes a message from the mailbox.
+func (m *Mailbox) Delete(id string) error {
+ messages, err := m.List()
+ if err != nil {
+ return err
+ }
+
+ var filtered []*Message
+ found := false
+ for _, msg := range messages {
+ if msg.ID == id {
+ found = true
+ } else {
+ filtered = append(filtered, msg)
+ }
+ }
+
+ if !found {
+ return ErrMessageNotFound
+ }
+
+ return m.rewrite(filtered)
+}
+
+// Count returns the total and unread message counts.
+func (m *Mailbox) Count() (total, unread int, err error) {
+ messages, err := m.List()
+ if err != nil {
+ return 0, 0, err
+ }
+
+ total = len(messages)
+ for _, msg := range messages {
+ if !msg.Read {
+ unread++
+ }
+ }
+
+ return total, unread, nil
+}
+
+// rewrite rewrites the mailbox with the given messages.
+func (m *Mailbox) rewrite(messages []*Message) error {
+ // Sort by timestamp (oldest first for JSONL)
+ sort.Slice(messages, func(i, j int) bool {
+ return messages[i].Timestamp.Before(messages[j].Timestamp)
+ })
+
+ // Write to temp file
+ tmpPath := m.path + ".tmp"
+ file, err := os.Create(tmpPath)
+ if err != nil {
+ return err
+ }
+
+ for _, msg := range messages {
+ data, err := json.Marshal(msg)
+ if err != nil {
+ file.Close()
+ os.Remove(tmpPath)
+ return err
+ }
+ file.WriteString(string(data) + "\n")
+ }
+
+ if err := file.Close(); err != nil {
+ os.Remove(tmpPath)
+ return err
+ }
+
+ // Atomic rename
+ return os.Rename(tmpPath, m.path)
+}
diff --git a/internal/mail/router.go b/internal/mail/router.go
new file mode 100644
index 00000000..b6c9ccf8
--- /dev/null
+++ b/internal/mail/router.go
@@ -0,0 +1,136 @@
+package mail
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/steveyegge/gastown/internal/tmux"
+)
+
+// Router handles message delivery and address resolution.
+type Router struct {
+ townRoot string
+ tmux *tmux.Tmux
+}
+
+// NewRouter creates a new mail router.
+func NewRouter(townRoot string) *Router {
+ return &Router{
+ townRoot: townRoot,
+ tmux: tmux.NewTmux(),
+ }
+}
+
+// Send delivers a message to its recipient.
+func (r *Router) Send(msg *Message) error {
+ // Resolve recipient mailbox path
+ mailboxPath, err := r.ResolveMailbox(msg.To)
+ if err != nil {
+ return fmt.Errorf("resolving address '%s': %w", msg.To, err)
+ }
+
+ // Append to mailbox
+ mailbox := NewMailbox(mailboxPath)
+ if err := mailbox.Append(msg); err != nil {
+ return fmt.Errorf("delivering message: %w", err)
+ }
+
+ // Optionally notify if recipient is a polecat with active session
+ if isPolecat(msg.To) && msg.Priority == PriorityHigh {
+ r.notifyPolecat(msg)
+ }
+
+ return nil
+}
+
+// ResolveMailbox converts an address to a mailbox file path.
+//
+// Address formats:
+// - mayor/ → /mayor/mail/inbox.jsonl
+// - /refinery → //refinery/mail/inbox.jsonl
+// - / → //polecats//mail/inbox.jsonl
+// - / → //mail/inbox.jsonl (rig broadcast)
+func (r *Router) ResolveMailbox(address string) (string, error) {
+ address = strings.TrimSpace(address)
+ if address == "" {
+ return "", fmt.Errorf("empty address")
+ }
+
+ // Mayor
+ if address == "mayor/" || address == "mayor" {
+ return filepath.Join(r.townRoot, "mayor", "mail", "inbox.jsonl"), nil
+ }
+
+ // Parse rig/target
+ parts := strings.SplitN(address, "/", 2)
+ if len(parts) < 2 {
+ return "", fmt.Errorf("invalid address format: %s", address)
+ }
+
+ rig := parts[0]
+ target := parts[1]
+
+ // Rig broadcast (empty target or just /)
+ if target == "" {
+ return filepath.Join(r.townRoot, rig, "mail", "inbox.jsonl"), nil
+ }
+
+ // Refinery
+ if target == "refinery" {
+ return filepath.Join(r.townRoot, rig, "refinery", "mail", "inbox.jsonl"), nil
+ }
+
+ // Polecat
+ return filepath.Join(r.townRoot, rig, "polecats", target, "mail", "inbox.jsonl"), nil
+}
+
+// GetMailbox returns a Mailbox for the given address.
+func (r *Router) GetMailbox(address string) (*Mailbox, error) {
+ path, err := r.ResolveMailbox(address)
+ if err != nil {
+ return nil, err
+ }
+ return NewMailbox(path), nil
+}
+
+// notifyPolecat sends a notification to a polecat's tmux session.
+func (r *Router) notifyPolecat(msg *Message) error {
+ // Parse rig/polecat from address
+ parts := strings.SplitN(msg.To, "/", 2)
+ if len(parts) != 2 {
+ return nil
+ }
+
+ rig := parts[0]
+ polecat := parts[1]
+
+ // Generate session name (matches session.Manager)
+ sessionID := fmt.Sprintf("gt-%s-%s", rig, polecat)
+
+ // Check if session exists
+ hasSession, err := r.tmux.HasSession(sessionID)
+ if err != nil || !hasSession {
+ return nil // No active session, skip notification
+ }
+
+ // Inject notification
+ notification := fmt.Sprintf("[MAIL] %s", msg.Subject)
+ return r.tmux.SendKeys(sessionID, notification)
+}
+
+// isPolecat checks if an address points to a polecat.
+func isPolecat(address string) bool {
+ // Not mayor, not refinery, has rig/name format
+ if strings.HasPrefix(address, "mayor") {
+ return false
+ }
+
+ parts := strings.SplitN(address, "/", 2)
+ if len(parts) != 2 {
+ return false
+ }
+
+ target := parts[1]
+ return target != "" && target != "refinery"
+}
diff --git a/internal/mail/types.go b/internal/mail/types.go
new file mode 100644
index 00000000..af15fa20
--- /dev/null
+++ b/internal/mail/types.go
@@ -0,0 +1,67 @@
+// Package mail provides JSONL-based messaging for agent communication.
+package mail
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "time"
+)
+
+// Priority levels for messages.
+type Priority string
+
+const (
+ // PriorityNormal is the default priority.
+ PriorityNormal Priority = "normal"
+
+ // PriorityHigh indicates an urgent message.
+ PriorityHigh Priority = "high"
+)
+
+// Message represents a mail message between agents.
+type Message struct {
+ // ID is a unique message identifier.
+ ID string `json:"id"`
+
+ // From is the sender address (e.g., "gastown/Toast" or "mayor/").
+ From string `json:"from"`
+
+ // To is the recipient address.
+ To string `json:"to"`
+
+ // Subject is a brief summary.
+ Subject string `json:"subject"`
+
+ // Body is the full message content.
+ Body string `json:"body"`
+
+ // Timestamp is when the message was sent.
+ Timestamp time.Time `json:"timestamp"`
+
+ // Read indicates if the message has been read.
+ Read bool `json:"read"`
+
+ // Priority is the message priority.
+ Priority Priority `json:"priority"`
+}
+
+// NewMessage creates a new message with a generated ID.
+func NewMessage(from, to, subject, body string) *Message {
+ return &Message{
+ ID: generateID(),
+ From: from,
+ To: to,
+ Subject: subject,
+ Body: body,
+ Timestamp: time.Now(),
+ Read: false,
+ Priority: PriorityNormal,
+ }
+}
+
+// generateID creates a random message ID.
+func generateID() string {
+ b := make([]byte, 8)
+ rand.Read(b)
+ return "msg-" + hex.EncodeToString(b)
+}