Skip to content

Commit f140ccd

Browse files
Nylas Chat (#22)
Co-authored-by: Qasim <qasim.m@nylas.com>
1 parent bbd77f5 commit f140ccd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

91 files changed

+9236
-503
lines changed

.gitignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
*plan.md
33

44
# Temporary refactoring/analysis documents
5-
REFACTORING_PROGRESS.md
6-
ENGINEERING_IMPROVEMENTS_SUMMARY.md
75
COVERAGE_REPORT.md
86
start.md
97
*_PROGRESS.md
@@ -26,6 +24,7 @@ CLAUDE.local.md
2624
.claude/settings.local.json
2725
.claude/*.local.*
2826
.claude/*/*.local.*
27+
*.session
2928

3029
# Session-specific progress tracking (personal, not shared)
3130
claude-progress.txt

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ make ci # Runs: fmt → vet → lint → test-unit → test-race → secu
6565
- **API**: Nylas v3 ONLY (never use v1/v2)
6666
- **Timezone Support**: Offline utilities + calendar integration ✅
6767
- **Email Signing**: GPG/PGP email signing (RFC 3156 PGP/MIME) ✅
68+
- **AI Chat**: Web-based chat interface using locally installed AI agents ✅
6869
- **Credential Storage**: System keyring (see below)
6970
- **Web UI**: Air - browser-based interface (localhost:7365)
7071

@@ -122,14 +123,15 @@ Credentials from `nylas auth config` are stored in the system keyring under serv
122123

123124
**Core files:** `cmd/nylas/main.go`, `internal/ports/nylas.go`, `internal/adapters/nylas/client.go`
124125

125-
**Quick lookup:** CLI helpers in `internal/cli/common/`, HTTP in `client.go`, Air at `internal/air/`
126+
**Quick lookup:** CLI helpers in `internal/cli/common/`, HTTP in `client.go`, Air at `internal/air/`, Chat at `internal/chat/`
126127

127128
**New packages (2024-2026):**
128129
- `internal/ports/output.go` - OutputWriter interface for pluggable formatting
129130
- `internal/adapters/output/` - Table, JSON, YAML, Quiet output adapters
130131
- `internal/httputil/` - HTTP response helpers (WriteJSON, LimitedBody, DecodeJSON)
131132
- `internal/adapters/gpg/` - GPG/PGP email signing service (2026)
132133
- `internal/adapters/mime/` - RFC 3156 PGP/MIME message builder (2026)
134+
- `internal/chat/` - AI chat interface with local agent support (2026)
133135

134136
**Full inventory:** `docs/ARCHITECTURE.md`
135137

@@ -195,6 +197,7 @@ Credentials from `nylas auth config` are stored in the system keyring under serv
195197
| `make ci` | Quick quality checks (no integration) |
196198
| `make build` | Build binary |
197199
| `nylas air` | Start Air web UI (localhost:7365) |
200+
| `nylas chat` | Start AI chat interface (localhost:7367) |
198201

199202
**Available targets:** Run `make help` or `make` to see all available commands
200203

cmd/nylas/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77

88
"github.com/nylas/cli/internal/air"
9+
"github.com/nylas/cli/internal/chat"
910
"github.com/nylas/cli/internal/cli"
1011
"github.com/nylas/cli/internal/cli/admin"
1112
"github.com/nylas/cli/internal/cli/ai"
@@ -54,6 +55,7 @@ func main() {
5455
rootCmd.AddCommand(cli.NewTUICmd())
5556
rootCmd.AddCommand(ui.NewUICmd())
5657
rootCmd.AddCommand(air.NewAirCmd())
58+
rootCmd.AddCommand(chat.NewChatCmd())
5759
rootCmd.AddCommand(update.NewUpdateCmd())
5860

5961
if err := cli.Execute(); err != nil {

docs/COMMANDS.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,64 @@ make test-air-integration # Run Air integration tests only
430430

431431
---
432432

433+
## Chat (`nylas chat`) - AI Chat Interface
434+
435+
Launch **Nylas Chat** - a web-based AI chat interface that can access your email, calendar, and contacts:
436+
437+
```bash
438+
nylas chat # Start with auto-detected agent (default port 7367)
439+
nylas chat --agent claude # Use specific agent (claude, codex, ollama)
440+
nylas chat --agent ollama --model llama2 # Use Ollama with specific model
441+
nylas chat --port 8080 # Custom port
442+
nylas chat --no-browser # Don't auto-open browser
443+
```
444+
445+
**Features:**
446+
- **Local AI agents:** Uses Claude, Codex, or Ollama installed on your system
447+
- **Email & Calendar access:** AI can read emails, check calendar, manage contacts
448+
- **Conversation history:** Persistent chat sessions stored locally
449+
- **Agent switching:** Change agents without restarting
450+
- **Web interface:** Clean, modern chat UI
451+
452+
**Supported Agents:**
453+
| Agent | Description | Auto-detected |
454+
|-------|-------------|---------------|
455+
| Claude | Anthropic's Claude (via `claude` CLI) ||
456+
| Codex | OpenAI Codex ||
457+
| Ollama | Local LLM runner (customizable models) ||
458+
459+
**Agent Detection:**
460+
The CLI automatically detects installed agents on your system. Use `--agent` to override the default selection.
461+
462+
**Conversation Storage:**
463+
- Location: `~/.config/nylas/chat/conversations/`
464+
- Format: JSON files per conversation
465+
- Persistent across sessions
466+
467+
**Security:**
468+
- Runs on localhost only (not accessible externally)
469+
- All data stored locally on your machine
470+
- Agent communication happens through local processes
471+
472+
**URL:** `http://localhost:7367` (default)
473+
474+
**Examples:**
475+
```bash
476+
# Quick start with best available agent
477+
nylas chat
478+
479+
# Force use of Claude
480+
nylas chat --agent claude
481+
482+
# Use Ollama with Mistral model
483+
nylas chat --agent ollama --model mistral
484+
485+
# Run on different port
486+
nylas chat --port 9000 --no-browser
487+
```
488+
489+
---
490+
433491
## MCP (Model Context Protocol)
434492

435493
Enable AI assistants (Claude Desktop, Cursor, Windsurf, VS Code) to interact with your email and calendar.

internal/adapters/nylas/mock_client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ type MockClient struct {
8888
CreateEventFunc func(ctx context.Context, grantID, calendarID string, req *domain.CreateEventRequest) (*domain.Event, error)
8989
UpdateEventFunc func(ctx context.Context, grantID, calendarID, eventID string, req *domain.UpdateEventRequest) (*domain.Event, error)
9090
DeleteEventFunc func(ctx context.Context, grantID, calendarID, eventID string) error
91+
92+
// Contact functions
93+
GetContactsFunc func(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error)
9194
}
9295

9396
// NewMockClient creates a new MockClient.

internal/adapters/nylas/mock_contacts.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import (
77
)
88

99
func (m *MockClient) GetContacts(ctx context.Context, grantID string, params *domain.ContactQueryParams) ([]domain.Contact, error) {
10+
if m.GetContactsFunc != nil {
11+
return m.GetContactsFunc(ctx, grantID, params)
12+
}
1013
return []domain.Contact{
1114
{
1215
ID: "contact-1",

internal/chat/agent.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Package chat provides an AI chat interface using locally installed CLI agents.
2+
package chat
3+
4+
import (
5+
"bytes"
6+
"context"
7+
"fmt"
8+
"os"
9+
"os/exec"
10+
"strings"
11+
)
12+
13+
// AgentType represents a supported AI agent.
14+
type AgentType string
15+
16+
const (
17+
AgentClaude AgentType = "claude"
18+
AgentCodex AgentType = "codex"
19+
AgentOllama AgentType = "ollama"
20+
)
21+
22+
// Agent represents a detected AI agent on the system.
23+
type Agent struct {
24+
Type AgentType `json:"type"`
25+
Path string `json:"path"`
26+
Model string `json:"model,omitempty"` // for ollama: model name
27+
Version string `json:"version,omitempty"` // detected version
28+
}
29+
30+
// DetectAgents scans the system for installed AI agents.
31+
// It checks for claude, codex, and ollama in $PATH.
32+
func DetectAgents() []Agent {
33+
agents := []Agent{}
34+
35+
checks := []struct {
36+
name AgentType
37+
binary string
38+
versionArgs []string
39+
}{
40+
{AgentClaude, "claude", []string{"--version"}},
41+
{AgentCodex, "codex", []string{"--version"}},
42+
{AgentOllama, "ollama", []string{"--version"}},
43+
}
44+
45+
for _, check := range checks {
46+
path, err := exec.LookPath(check.binary)
47+
if err != nil {
48+
continue
49+
}
50+
51+
agent := Agent{
52+
Type: check.name,
53+
Path: path,
54+
}
55+
56+
// Try to get version
57+
if len(check.versionArgs) > 0 {
58+
out, err := exec.Command(path, check.versionArgs...).Output()
59+
if err == nil {
60+
agent.Version = strings.TrimSpace(string(out))
61+
}
62+
}
63+
64+
// Default model for ollama
65+
if check.name == AgentOllama {
66+
agent.Model = "mistral"
67+
}
68+
69+
agents = append(agents, agent)
70+
}
71+
72+
return agents
73+
}
74+
75+
// FindAgent returns the first agent matching the given type, or nil.
76+
func FindAgent(agents []Agent, agentType AgentType) *Agent {
77+
for i := range agents {
78+
if agents[i].Type == agentType {
79+
return &agents[i]
80+
}
81+
}
82+
return nil
83+
}
84+
85+
// Run executes the agent with the given prompt and returns the response.
86+
// Each agent type has a different invocation pattern:
87+
// - claude: claude -p --output-format text "prompt"
88+
// - codex: codex exec "prompt"
89+
// - ollama: echo "prompt" | ollama run <model>
90+
func (a *Agent) Run(ctx context.Context, prompt string) (string, error) {
91+
switch a.Type {
92+
case AgentClaude:
93+
return a.runClaude(ctx, prompt)
94+
case AgentCodex:
95+
return a.runCodex(ctx, prompt)
96+
case AgentOllama:
97+
return a.runOllama(ctx, prompt)
98+
default:
99+
return "", fmt.Errorf("unsupported agent type: %s", a.Type)
100+
}
101+
}
102+
103+
// cleanEnv returns the current environment with nesting-detection vars removed
104+
// so agent subprocesses don't refuse to start inside our server process.
105+
func cleanEnv() []string {
106+
skip := map[string]bool{
107+
"CLAUDECODE": true,
108+
"CLAUDE_CODE": true,
109+
"CODEX_SANDBOX": true,
110+
"CODEX_ENV": true,
111+
"INSIDE_CODEX": true,
112+
}
113+
114+
var env []string
115+
for _, e := range os.Environ() {
116+
key, _, _ := strings.Cut(e, "=")
117+
if !skip[key] {
118+
env = append(env, e)
119+
}
120+
}
121+
return env
122+
}
123+
124+
func (a *Agent) runClaude(ctx context.Context, prompt string) (string, error) {
125+
cmd := exec.CommandContext(ctx, a.Path, "-p", "--output-format", "text", prompt)
126+
cmd.Env = cleanEnv()
127+
var stdout, stderr bytes.Buffer
128+
cmd.Stdout = &stdout
129+
cmd.Stderr = &stderr
130+
131+
if err := cmd.Run(); err != nil {
132+
return "", fmt.Errorf("claude error: %w: %s", err, stderr.String())
133+
}
134+
135+
return strings.TrimSpace(stdout.String()), nil
136+
}
137+
138+
func (a *Agent) runCodex(ctx context.Context, prompt string) (string, error) {
139+
cmd := exec.CommandContext(ctx, a.Path, "exec", prompt)
140+
cmd.Env = cleanEnv()
141+
var stdout, stderr bytes.Buffer
142+
cmd.Stdout = &stdout
143+
cmd.Stderr = &stderr
144+
145+
if err := cmd.Run(); err != nil {
146+
return "", fmt.Errorf("codex error: %w: %s", err, stderr.String())
147+
}
148+
149+
return strings.TrimSpace(stdout.String()), nil
150+
}
151+
152+
func (a *Agent) runOllama(ctx context.Context, prompt string) (string, error) {
153+
model := a.Model
154+
if model == "" {
155+
model = "mistral"
156+
}
157+
158+
cmd := exec.CommandContext(ctx, a.Path, "run", model)
159+
cmd.Env = cleanEnv()
160+
cmd.Stdin = strings.NewReader(prompt)
161+
var stdout, stderr bytes.Buffer
162+
cmd.Stdout = &stdout
163+
cmd.Stderr = &stderr
164+
165+
if err := cmd.Run(); err != nil {
166+
return "", fmt.Errorf("ollama error: %w: %s", err, stderr.String())
167+
}
168+
169+
return strings.TrimSpace(stdout.String()), nil
170+
}
171+
172+
// String returns a human-readable description of the agent.
173+
func (a *Agent) String() string {
174+
s := string(a.Type)
175+
if a.Model != "" {
176+
s += " (" + a.Model + ")"
177+
}
178+
if a.Version != "" {
179+
s += " " + a.Version
180+
}
181+
return s
182+
}

0 commit comments

Comments
 (0)