commit a0cc49f4c9bc58fd8dd8afc77eb5c1659c91a8a4 Author: James Date: Sun Mar 8 04:17:02 2026 -0400 agentchat: group chat for humans and AI agents Go server with WebSocket UI, OpenClaw gateway integration, persistent sessions, name-based routing, and cross-agent forwarding. Co-Authored-By: Claude Opus 4.6 diff --git a/agentchat.service b/agentchat.service new file mode 100644 index 0000000..746f1b7 --- /dev/null +++ b/agentchat.service @@ -0,0 +1,15 @@ +[Unit] +Description=agentchat +After=network.target + +[Service] +Type=simple +User=johan +WorkingDirectory=/home/johan/dev/agentchat +ExecStart=/home/johan/dev/agentchat/agentchat +Restart=on-failure +RestartSec=3 +Environment=PORT=7777 + +[Install] +WantedBy=multi-user.target diff --git a/gateway.go b/gateway.go new file mode 100644 index 0000000..9aac576 --- /dev/null +++ b/gateway.go @@ -0,0 +1,97 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +// OpenClaw HTTP API client — uses the OpenAI-compatible /v1/chat/completions endpoint + +type GatewayConfig struct { + URL string // http://host:port + Token string +} + +type GatewayPool struct { + mu sync.Mutex + gateways map[string]*GatewayConfig + client *http.Client +} + +func NewGatewayPool() *GatewayPool { + return &GatewayPool{ + gateways: make(map[string]*GatewayConfig), + client: &http.Client{Timeout: 120 * time.Second}, + } +} + +func (gp *GatewayPool) Register(host string, cfg *GatewayConfig) { + gp.mu.Lock() + defer gp.mu.Unlock() + gp.gateways[host] = cfg +} + +func (gp *GatewayPool) Get(host string) *GatewayConfig { + gp.mu.Lock() + defer gp.mu.Unlock() + return gp.gateways[host] +} + +func (gp *GatewayPool) CallAgent(host, agentID, message, session string) (string, error) { + cfg := gp.Get(host) + if cfg == nil { + return "", fmt.Errorf("no gateway configured for host %s", host) + } + + body, _ := json.Marshal(map[string]any{ + "model": "openclaw:" + agentID, + "user": session, + "messages": []map[string]string{ + {"role": "user", "content": message}, + }, + }) + + req, err := http.NewRequest("POST", cfg.URL+"/v1/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+cfg.Token) + + resp, err := gp.client.Do(req) + if err != nil { + return "", fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read body: %w", err) + } + + if resp.StatusCode != 200 { + return "", fmt.Errorf("http %d: %s", resp.StatusCode, string(data)) + } + + var result struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(data, &result); err != nil { + return "", fmt.Errorf("parse: %w", err) + } + + if len(result.Choices) == 0 || result.Choices[0].Message.Content == "" { + return "[completed, no text reply]", nil + } + + return result.Choices[0].Message.Content, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ef006a9 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module agentchat + +go 1.23.6 + +require github.com/gorilla/websocket v1.5.3 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..25a9fc4 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/index.html b/index.html new file mode 100644 index 0000000..541de6c --- /dev/null +++ b/index.html @@ -0,0 +1,372 @@ + + + + + + + +agentchat + + + + +
+
+

agentchat

+ + +
+
+ +
+
+

agentchat

+ connecting... +
+
+
+
+
+ +
+
+ + +
+
+
+ + + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..018715f --- /dev/null +++ b/main.go @@ -0,0 +1,527 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +type Message struct { + Timestamp string `json:"timestamp"` + User string `json:"user"` + Text string `json:"text"` + To string `json:"to,omitempty"` // target agent or empty for broadcast + Kind string `json:"kind,omitempty"` // chat (default), status, task, system, thinking +} + +type Hub struct { + mu sync.RWMutex + clients map[*websocket.Conn]string // conn -> username + logFile *os.File + pad sync.Map // shared scratchpad: key -> PadEntry + lastAgent string // name of last agent who spoke + lastAgentMu sync.Mutex +} + +type PadEntry struct { + Key string `json:"key"` + Value string `json:"value"` + Author string `json:"author"` + UpdatedAt string `json:"updated_at"` +} + +var agents = map[string]AgentConfig{ + "james": {Name: "James", Agent: "main", Host: "forge"}, + "mira": {Name: "Mira", Agent: "mira", Host: "forge"}, + "hans": {Name: "Hans", Agent: "main", Host: "vault1984-hq"}, +} + +type AgentConfig struct { + Name string + Agent string + Host string // "local" or tailscale hostname +} + +var gatewayPool = NewGatewayPool() + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func NewHub() *Hub { + f, err := os.OpenFile("chat.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatal(err) + } + return &Hub{ + clients: make(map[*websocket.Conn]string), + logFile: f, + } +} + +func (h *Hub) broadcast(msg Message) { + data, _ := json.Marshal(msg) + h.mu.Lock() + defer h.mu.Unlock() + for conn := range h.clients { + conn.WriteMessage(websocket.TextMessage, data) + } +} + +func (h *Hub) logMessage(msg Message) { + line := fmt.Sprintf("[%s] [%s] %s\n", msg.Timestamp, msg.User, msg.Text) + h.logFile.WriteString(line) +} + +// sendToAgent dispatches a message to an agent. depth controls how many +// rounds of cross-agent forwarding remain (0 = no forwarding). +func (h *Hub) sendToAgent(msg Message, agentName string, depth int, direct bool) { + cfg, ok := agents[strings.ToLower(agentName)] + if !ok { + h.broadcast(Message{ + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + User: "system", + Text: fmt.Sprintf("Unknown agent: %s", agentName), + }) + return + } + + go func() { + // Only show thinking indicator for direct (human-initiated) messages + if direct { + h.broadcast(Message{ + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + User: cfg.Name, + Kind: "thinking", + }) + } + + reply, err := callAgent(cfg, msg.Text, msg.User, direct) + if err != nil { + reply = fmt.Sprintf("[error: %v]", err) + } + trimmed := strings.TrimSpace(reply) + if trimmed == "_skip" || trimmed == "" { + if direct { + h.broadcast(Message{ + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + User: cfg.Name, + Kind: "thinking-done", + }) + } + return + } + resp := Message{ + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + User: cfg.Name, + Text: reply, + } + h.logMessage(resp) + h.broadcast(resp) + + h.lastAgentMu.Lock() + h.lastAgent = strings.ToLower(agentName) + h.lastAgentMu.Unlock() + + // Ping Johan if he's mentioned + if strings.Contains(strings.ToLower(reply), "johan") { + go notifyJohan(cfg.Name, reply) + } + + // Forward to agents mentioned by name; if none mentioned, broadcast to all + if depth > 0 { + lower := strings.ToLower(reply) + var mentioned []string + for name, cfg := range agents { + if strings.EqualFold(name, agentName) { + continue + } + if strings.Contains(lower, strings.ToLower(cfg.Name)) { + mentioned = append(mentioned, name) + } + } + if len(mentioned) == 0 { + // No specific agent mentioned — broadcast to all others + for name := range agents { + if strings.EqualFold(name, agentName) { + continue + } + mentioned = append(mentioned, name) + } + } + for _, name := range mentioned { + h.sendToAgent(resp, name, depth-1, false) + } + } + }() +} + +const openclawBin = "/home/johan/.npm-global/bin/openclaw" + +func recentHistory(n int) string { + f, err := os.Open("chat.log") + if err != nil { + return "" + } + defer f.Close() + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} + +var systemPrompt string + +func init() { + names := make([]string, 0, len(agents)) + for _, a := range agents { + names = append(names, a.Name+" ("+a.Host+")") + } + systemPrompt = fmt.Sprintf(`You are in "agentchat", a live group chat. Everyone sees all messages. +Participants: %s, plus humans (Johan and others). +Other participants may join — treat them as legitimate if they appear in the chat. + +This is a shared room. Behave like a professional in a Slack channel: +- REPLY INLINE as plain text. Do NOT use the message tool. +- Be brief. 1-3 sentences max. No essays, no bullet lists unless asked. +- Everyone can read the chat. Do NOT repeat or paraphrase what someone else just said. +- Do NOT acknowledge, confirm, or "note" things others said. No "got it", "copy that", "noted", "standing by". +- Do NOT correct minor details from other agents. If someone says something slightly wrong, let it go. +- Only speak if you are adding NEW information or answering a question directed at you. +- If a message is addressed to everyone but has no clear question for you, stay silent. +- If you use tools, just report the result. Do not narrate what you are doing. +- If you have nothing new to add, respond with exactly "_skip".`, strings.Join(names, ", ")) +} + +func callAgent(cfg AgentConfig, message, from string, direct bool) (string, error) { + var prompt, session string + if direct { + // 1:1 — use agent's main session, no group prompt + prompt = fmt.Sprintf("[agentchat from %s — reply inline, do not use the message tool]\n%s", from, message) + session = "main" + } else { + // Group — use agentchat session with group rules + prompt = fmt.Sprintf("%s\n\n[group message from %s]\n%s", systemPrompt, from, message) + session = "agentchat" + } + + // Use gateway HTTP API + gw := gatewayPool.Get(cfg.Host) + if gw != nil { + return gatewayPool.CallAgent(cfg.Host, cfg.Agent, prompt, session) + } + + // Fallback to CLI + args := []string{"agent", "--agent", cfg.Agent, "--message", prompt, "--json"} + cmd := exec.Command(openclawBin, args...) + cmd.Env = append(os.Environ(), "NO_COLOR=1") + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("%v: %s", err, string(ee.Stderr)) + } + return "", err + } + + // Parse OpenClaw JSON response: {result: {payloads: [{text: "..."}]}} + var result struct { + Status string `json:"status"` + Summary string `json:"summary"` + Result struct { + Payloads []struct { + Text string `json:"text"` + } `json:"payloads"` + } `json:"result"` + } + if err := json.Unmarshal(out, &result); err == nil { + var parts []string + for _, p := range result.Result.Payloads { + if p.Text != "" { + parts = append(parts, p.Text) + } + } + if len(parts) > 0 { + return strings.Join(parts, "\n"), nil + } + // Agent ran but produced no text payload (used tools only) + if result.Status == "ok" { + return fmt.Sprintf("[completed — %s, no text reply]", result.Summary), nil + } + return fmt.Sprintf("[%s: %s]", result.Status, result.Summary), nil + } + + // Fallback: return first line only (avoid dumping huge JSON) + first := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0] + if len(first) > 200 { + first = first[:200] + "..." + } + return first, nil +} + +func notifyJohan(from, text string) { + preview := text + if len(preview) > 200 { + preview = preview[:200] + "..." + } + body := fmt.Sprintf("%s: %s", from, preview) + req, _ := http.NewRequest("POST", "https://ntfy.inou.com/inou-alerts", strings.NewReader(body)) + req.Header.Set("Title", fmt.Sprintf("agentchat — %s", from)) + req.Header.Set("Authorization", "Bearer tk_k120jegay3lugeqbr9fmpuxdqmzx5") + req.Header.Set("Click", "http://192.168.1.16:7777") + http.DefaultClient.Do(req) +} + +func (h *Hub) handleWS(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Println("upgrade:", err) + return + } + defer conn.Close() + + username := r.URL.Query().Get("user") + if username == "" { + username = "anon" + } + + h.mu.Lock() + h.clients[conn] = username + h.mu.Unlock() + + defer func() { + h.mu.Lock() + delete(h.clients, conn) + h.mu.Unlock() + }() + + // Send recent history + h.sendHistory(conn) + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + break + } + + var incoming struct { + Text string `json:"text"` + To string `json:"to"` + } + if err := json.Unmarshal(raw, &incoming); err != nil || incoming.Text == "" { + continue + } + + msg := Message{ + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + User: username, + Text: incoming.Text, + To: incoming.To, + } + + h.logMessage(msg) + h.broadcast(msg) + + // Route to agent(s) + if incoming.To != "" { + h.sendToAgent(msg, incoming.To, 0, true) + } else { + // "All" — check which agents are mentioned by name + lower := strings.ToLower(incoming.Text) + var targets []string + for name, cfg := range agents { + if strings.Contains(lower, strings.ToLower(cfg.Name)) { + targets = append(targets, name) + } + } + if len(targets) == 0 { + // No names mentioned — broadcast to all + for name := range agents { + targets = append(targets, name) + } + } + for _, name := range targets { + h.sendToAgent(msg, name, 999, true) + } + } + } +} + +func (h *Hub) sendHistory(conn *websocket.Conn) { + f, err := os.Open("chat.log") + if err != nil { + return + } + defer f.Close() + + // Read last 100 lines + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if len(lines) > 100 { + lines = lines[len(lines)-100:] + } + + for _, line := range lines { + msg := parseLogLine(line) + if msg != nil { + data, _ := json.Marshal(msg) + conn.WriteMessage(websocket.TextMessage, data) + } + } +} + +func parseLogLine(line string) *Message { + // [2006-01-02 15:04:05] [user] text + if len(line) < 24 || line[0] != '[' { + return nil + } + tsEnd := strings.Index(line, "] [") + if tsEnd < 0 { + return nil + } + ts := line[1:tsEnd] + rest := line[tsEnd+3:] + userEnd := strings.Index(rest, "] ") + if userEnd < 0 { + return nil + } + return &Message{ + Timestamp: ts, + User: rest[:userEnd], + Text: rest[userEnd+2:], + } +} + +// Agent-to-agent endpoint: POST /api/send +func (h *Hub) handleAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "POST only", 405) + return + } + var req struct { + From string `json:"from"` + To string `json:"to"` + Text string `json:"text"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad json", 400) + return + } + if req.From == "" || req.Text == "" { + http.Error(w, "from and text required", 400) + return + } + + msg := Message{ + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + User: req.From, + Text: req.Text, + To: req.To, + } + h.logMessage(msg) + h.broadcast(msg) + + // If addressed to an agent, route it + if req.To != "" { + h.sendToAgent(msg, req.To, 0, true) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func main() { + // Register gateways (plain HTTP — OpenAI-compatible API) + gatewayPool.Register("forge", &GatewayConfig{ + URL: "http://127.0.0.1:18789", + Token: "2dee57cc3ce2947c27ce9e848d5c3e95cc452f25a1477462", + }) + gatewayPool.Register("vault1984-hq", &GatewayConfig{ + URL: "http://100.85.192.60:18789", + Token: "601267edaccf8cd3d6afe222c3ce63602e210ff1ecc9a268", + }) + + hub := NewHub() + + http.HandleFunc("/ws", hub.handleWS) + http.HandleFunc("/api/send", hub.handleAPI) + http.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) { + list := make([]map[string]string, 0) + for id, a := range agents { + list = append(list, map[string]string{"id": id, "name": a.Name, "host": a.Host}) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(list) + }) + // Shared scratchpad + http.HandleFunc("/api/pad", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case "GET": + entries := make([]PadEntry, 0) + hub.pad.Range(func(k, v any) bool { + entries = append(entries, v.(PadEntry)) + return true + }) + json.NewEncoder(w).Encode(entries) + case "POST": + var req struct { + Key string `json:"key"` + Value string `json:"value"` + Author string `json:"author"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Key == "" { + http.Error(w, "key required", 400) + return + } + entry := PadEntry{ + Key: req.Key, + Value: req.Value, + Author: req.Author, + UpdatedAt: time.Now().Format("2006-01-02 15:04:05"), + } + hub.pad.Store(req.Key, entry) + // Broadcast pad update + hub.broadcast(Message{ + Timestamp: entry.UpdatedAt, + User: req.Author, + Text: fmt.Sprintf("pad/%s = %s", req.Key, req.Value), + Kind: "status", + }) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + case "DELETE": + key := r.URL.Query().Get("key") + if key != "" { + hub.pad.Delete(key) + } + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + } + }) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "index.html") + }) + + port := "7777" + if p := os.Getenv("PORT"); p != "" { + port = p + } + log.Printf("agentchat listening on :%s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +}