diff --git a/.gitignore b/.gitignore
index fdd7bb78..5a08b96f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,4 +34,6 @@ ui/web/pnpm-lock.yaml
tmp
.claude/
-deploy-*.sh
\ No newline at end of file
+deploy-*.sh
+
+docs/technical-diaries
\ No newline at end of file
diff --git a/cmd/gateway.go b/cmd/gateway.go
index 4fca07f4..9d587471 100644
--- a/cmd/gateway.go
+++ b/cmd/gateway.go
@@ -557,7 +557,7 @@ func runGateway() {
}
wireManagedExtras(managedStores, agentRouter, providerRegistry, msgBus, sessStore, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader)
- agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH := wireManagedHTTP(managedStores, cfg.Gateway.Token, msgBus, toolsReg)
+ agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH := wireManagedHTTP(managedStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry)
if agentsH != nil {
server.SetAgentsHandler(agentsH)
}
@@ -614,7 +614,6 @@ func runGateway() {
// Managed mode: load channel instances from DB first.
var instanceLoader *channels.InstanceLoader
- var loadedNames map[string]struct{}
if managedStores != nil && managedStores.ChannelInstances != nil {
instanceLoader = channels.NewInstanceLoader(managedStores.ChannelInstances, managedStores.Agents, channelMgr, msgBus, pairingStore)
instanceLoader.RegisterFactory("telegram", telegram.FactoryWithAgentStore(managedStores.Agents))
@@ -625,21 +624,17 @@ func runGateway() {
if err := instanceLoader.LoadAll(context.Background()); err != nil {
slog.Error("failed to load channel instances from DB", "error", err)
}
- loadedNames = instanceLoader.LoadedNames()
}
- // Register config-based channels as fallback.
- // Telegram: kept for backward compat (only channel with production data).
- // Other channels: skip config registration in managed mode if any DB instances loaded.
- if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" {
- if _, loaded := loadedNames["telegram"]; !loaded {
- tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore, nil)
- if err != nil {
- slog.Error("failed to initialize telegram channel", "error", err)
- } else {
- channelMgr.RegisterChannel("telegram", tg)
- slog.Info("telegram channel enabled (config)")
- }
+ // Register config-based channels as fallback (standalone mode only).
+ // In managed mode, channels are loaded from DB via instanceLoader — skip config-based registration.
+ if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" && instanceLoader == nil {
+ tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore, nil)
+ if err != nil {
+ slog.Error("failed to initialize telegram channel", "error", err)
+ } else {
+ channelMgr.RegisterChannel("telegram", tg)
+ slog.Info("telegram channel enabled (config)")
}
}
diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go
index 95eac4db..8504e635 100644
--- a/cmd/gateway_managed.go
+++ b/cmd/gateway_managed.go
@@ -271,7 +271,7 @@ func wireManagedExtras(
}
// wireManagedHTTP creates managed-mode HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers).
-func wireManagedHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler) {
+func wireManagedHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler) {
var agentsH *httpapi.AgentsHandler
var skillsH *httpapi.SkillsHandler
var tracesH *httpapi.TracesHandler
@@ -281,7 +281,11 @@ func wireManagedHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus,
var providersH *httpapi.ProvidersHandler
if stores != nil && stores.Agents != nil {
- agentsH = httpapi.NewAgentsHandler(stores.Agents, token, msgBus)
+ var summoner *httpapi.AgentSummoner
+ if providerReg != nil {
+ summoner = httpapi.NewAgentSummoner(stores.Agents, providerReg, msgBus)
+ }
+ agentsH = httpapi.NewAgentsHandler(stores.Agents, token, msgBus, summoner)
}
if stores != nil && stores.Skills != nil {
diff --git a/internal/agent/loop.go b/internal/agent/loop.go
index c1a57bf6..ba2b6019 100644
--- a/internal/agent/loop.go
+++ b/internal/agent/loop.go
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"log/slog"
+ "os"
+ "path/filepath"
"sort"
"strings"
"sync"
@@ -323,6 +325,17 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
ctx = store.WithSenderID(ctx, req.SenderID)
}
+ // Per-user workspace isolation (managed mode only).
+ // Each user gets a subdirectory within the agent's workspace.
+ if l.agentUUID != uuid.Nil && l.workspace != "" {
+ effectiveWorkspace := l.workspace
+ if req.UserID != "" {
+ effectiveWorkspace = filepath.Join(l.workspace, sanitizePathSegment(req.UserID))
+ os.MkdirAll(effectiveWorkspace, 0755)
+ }
+ ctx = tools.WithToolWorkspace(ctx, effectiveWorkspace)
+ }
+
// Ensure per-user context files exist (first-chat seeding, managed mode)
if l.ensureUserFiles != nil && req.UserID != "" {
if _, loaded := l.seededUsers.LoadOrStore(req.UserID, true); !loaded {
@@ -653,3 +666,17 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
Usage: &totalUsage,
}, nil
}
+
+// sanitizePathSegment makes a userID safe for use as a directory name.
+// Replaces colons, spaces, and other unsafe chars with underscores.
+func sanitizePathSegment(s string) string {
+ var b strings.Builder
+ for _, r := range s {
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
+ b.WriteRune(r)
+ } else {
+ b.WriteByte('_')
+ }
+ }
+ return b.String()
+}
diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go
index 8977d7b1..895a3da4 100644
--- a/internal/agent/loop_history.go
+++ b/internal/agent/loop_history.go
@@ -4,8 +4,10 @@ import (
"context"
"fmt"
"log/slog"
+ "path/filepath"
"time"
+ "github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
@@ -22,10 +24,16 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
_, hasSpawn := l.tools.Get("spawn")
_, hasSkillSearch := l.tools.Get("skill_search")
+ // Per-user workspace: show the user's subdirectory in the system prompt (managed mode)
+ promptWorkspace := l.workspace
+ if l.agentUUID != uuid.Nil && userID != "" && l.workspace != "" {
+ promptWorkspace = filepath.Join(l.workspace, sanitizePathSegment(userID))
+ }
+
systemPrompt := BuildSystemPrompt(SystemPromptConfig{
AgentID: l.id,
Model: l.model,
- Workspace: l.workspace,
+ Workspace: promptWorkspace,
Channel: channel,
OwnerIDs: l.ownerIDs,
Mode: mode,
diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go
index 619dfbb0..c100fbea 100644
--- a/internal/agent/resolver.go
+++ b/internal/agent/resolver.go
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"log/slog"
+ "os"
+ "path/filepath"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
@@ -113,6 +115,16 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
sandboxWorkspaceAccess = string(resolved.WorkspaceAccess)
}
+ // Expand ~ in workspace path and ensure directory exists
+ workspace := ag.Workspace
+ if workspace != "" {
+ workspace = config.ExpandHome(workspace)
+ if !filepath.IsAbs(workspace) {
+ workspace, _ = filepath.Abs(workspace)
+ }
+ os.MkdirAll(workspace, 0755)
+ }
+
// Per-agent custom tools (clone registry if agent has custom tools)
toolsReg := deps.Tools
if deps.DynamicLoader != nil {
@@ -131,7 +143,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
Model: ag.Model,
ContextWindow: contextWindow,
MaxIterations: maxIter,
- Workspace: ag.Workspace,
+ Workspace: workspace,
Bus: deps.Bus,
Sessions: deps.Sessions,
Tools: toolsReg,
diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go
index 11a1032c..ca4ff79d 100644
--- a/internal/agent/systemprompt.go
+++ b/internal/agent/systemprompt.go
@@ -83,9 +83,8 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string {
lines = append(lines,
"## FIRST RUN — MANDATORY",
"",
- "BOOTSTRAP.md is loaded below in Project Context. This is your FIRST TIME running.",
- "You MUST follow BOOTSTRAP.md instructions: introduce yourself, ask who the user is,",
- "figure out your name/creature/vibe/emoji together, then update IDENTITY.md and USER.md.",
+ "BOOTSTRAP.md is loaded below in Project Context. This is your FIRST interaction with this user.",
+ "You MUST follow BOOTSTRAP.md instructions immediately.",
"Do NOT give a generic greeting. Do NOT ignore this. Read BOOTSTRAP.md and follow it NOW.",
"",
)
diff --git a/internal/bootstrap/seed_store.go b/internal/bootstrap/seed_store.go
index be231137..cdb68172 100644
--- a/internal/bootstrap/seed_store.go
+++ b/internal/bootstrap/seed_store.go
@@ -36,6 +36,11 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid.
var seeded []string
for _, name := range templateFiles {
+ // USER.md is always per-user for predefined agents — don't seed at agent level.
+ // Having it at agent level alongside the user copy can confuse the model.
+ if name == UserFile {
+ continue
+ }
if hasContent[name] {
continue
}
@@ -71,13 +76,15 @@ var userSeedFilesOpen = []string{
}
// userSeedFilesPredefined is the set of files seeded per-user for predefined agents.
+// Includes BOOTSTRAP.md for user onboarding (uses BOOTSTRAP_PREDEFINED.md template).
var userSeedFilesPredefined = []string{
UserFile,
+ BootstrapFile,
}
// SeedUserFiles seeds embedded templates into user_context_files for a new user.
// For "open" agents: all 7 files (including BOOTSTRAP.md).
-// For "predefined" agents: only USER.md.
+// For "predefined" agents: USER.md + BOOTSTRAP.md (user-focused onboarding template).
// Only writes files that don't already exist — safe to call multiple times.
// Returns the list of file names that were seeded.
func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uuid.UUID, userID, agentType string) ([]string, error) {
@@ -104,7 +111,13 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui
continue // already has content, don't overwrite
}
- content, err := templateFS.ReadFile(filepath.Join("templates", name))
+ // Predefined agents use a user-focused bootstrap template
+ templateName := name
+ if agentType == "predefined" && name == BootstrapFile {
+ templateName = "BOOTSTRAP_PREDEFINED.md"
+ }
+
+ content, err := templateFS.ReadFile(filepath.Join("templates", templateName))
if err != nil {
slog.Warn("bootstrap: failed to read embedded template for user seed", "file", name, "error", err)
continue
diff --git a/internal/bootstrap/templates/BOOTSTRAP_PREDEFINED.md b/internal/bootstrap/templates/BOOTSTRAP_PREDEFINED.md
new file mode 100644
index 00000000..899c37b8
--- /dev/null
+++ b/internal/bootstrap/templates/BOOTSTRAP_PREDEFINED.md
@@ -0,0 +1,44 @@
+# BOOTSTRAP.md - Welcome, New User
+
+_A new user just started chatting with you. Time to get to know them._
+
+## The Conversation
+
+Don't interrogate. Don't be robotic. Just... talk.
+
+Start by introducing yourself briefly — your name, what you do, how you can help.
+Then get to know them naturally:
+
+1. **Their name** — What should you call them?
+2. **Their language** — What language do they prefer? (Switch to it if needed)
+3. **Their timezone** — Where are they? (Helps with scheduling and context)
+4. **Their needs** — What are they hoping you can help with?
+
+Keep it conversational. One or two questions at a time, not a form.
+Match the user's tone and language — if they're casual, be casual back.
+
+## After You Know Them
+
+Update `USER.md` immediately with what you learned. Use the `write_file` tool NOW:
+
+```
+write_file("USER.md", "# USER.md - About Your Human\n\n- **Name:** (their name)\n- **What to call them:** (how they want to be addressed)\n- **Pronouns:** (if shared)\n- **Timezone:** (their timezone)\n- **Language:** (their preferred language)\n- **Notes:** (anything else you learned)\n")
+```
+
+Do NOT skip this step. Do NOT just say you noted it — actually call `write_file` to save it.
+
+## When You're Done
+
+Mark bootstrap as complete by writing empty content to this file:
+
+```
+write_file("BOOTSTRAP.md", "")
+```
+
+Do NOT use `rm` or `exec` to delete it. The empty write signals the system that onboarding is finished.
+
+You MUST call both `write_file` calls above before moving on to normal conversation.
+
+---
+
+_Make a good first impression._
diff --git a/internal/http/agents.go b/internal/http/agents.go
index 313387bf..fb45ebd2 100644
--- a/internal/http/agents.go
+++ b/internal/http/agents.go
@@ -2,6 +2,7 @@ package http
import (
"encoding/json"
+ "fmt"
"log/slog"
"net/http"
@@ -15,14 +16,15 @@ import (
// AgentsHandler handles agent CRUD and sharing endpoints (managed mode only).
type AgentsHandler struct {
- agents store.AgentStore
- token string
- msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
+ agents store.AgentStore
+ token string
+ msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
+ summoner *AgentSummoner // LLM-based agent setup (nil = disabled)
}
// NewAgentsHandler creates a handler for agent management endpoints.
-func NewAgentsHandler(agents store.AgentStore, token string, msgBus *bus.MessageBus) *AgentsHandler {
- return &AgentsHandler{agents: agents, token: token, msgBus: msgBus}
+func NewAgentsHandler(agents store.AgentStore, token string, msgBus *bus.MessageBus, summoner *AgentSummoner) *AgentsHandler {
+ return &AgentsHandler{agents: agents, token: token, msgBus: msgBus, summoner: summoner}
}
// emitCacheInvalidate broadcasts a cache invalidation event if msgBus is set.
@@ -46,6 +48,7 @@ func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/agents/{id}/shares", h.authMiddleware(h.handleListShares))
mux.HandleFunc("POST /v1/agents/{id}/shares", h.authMiddleware(h.handleShare))
mux.HandleFunc("DELETE /v1/agents/{id}/shares/{userID}", h.authMiddleware(h.handleRevokeShare))
+ mux.HandleFunc("POST /v1/agents/{id}/regenerate", h.authMiddleware(h.handleRegenerate))
}
func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
@@ -101,23 +104,48 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
}
req.OwnerID = userID
- if req.Status == "" {
- req.Status = "active"
- }
if req.AgentType == "" {
req.AgentType = "open"
}
+ if req.ContextWindow <= 0 {
+ req.ContextWindow = 200000
+ }
+ if req.MaxToolIterations <= 0 {
+ req.MaxToolIterations = 20
+ }
+ if req.Workspace == "" {
+ if req.IsDefault {
+ req.Workspace = "~/.goclaw/workspace"
+ } else {
+ req.Workspace = fmt.Sprintf("~/.goclaw/%s-workspace", req.AgentKey)
+ }
+ }
+ req.RestrictToWorkspace = true
+
+ // Check if predefined agent has a description for LLM summoning
+ description := extractDescription(req.OtherConfig)
+ if req.AgentType == "predefined" && description != "" && h.summoner != nil {
+ req.Status = "summoning"
+ } else if req.Status == "" {
+ req.Status = "active"
+ }
if err := h.agents.Create(r.Context(), &req); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
- // Seed context files into agent_context_files (skipped for open agents)
+ // Seed context files into agent_context_files (skipped for open agents).
+ // For summoning agents, templates serve as fallback if LLM fails.
if _, err := bootstrap.SeedToStore(r.Context(), h.agents, req.ID, req.AgentType); err != nil {
slog.Warn("failed to seed context files for new agent", "agent", req.AgentKey, "error", err)
}
+ // Start LLM summoning in background if applicable
+ if req.Status == "summoning" {
+ go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description)
+ }
+
writeJSON(w, http.StatusCreated, req)
}
@@ -336,6 +364,69 @@ func (h *AgentsHandler) handleRevokeShare(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
}
+func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request) {
+ userID := store.UserIDFromContext(r.Context())
+ id, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+ return
+ }
+
+ // Only owner can regenerate
+ ag, err := h.agents.GetByID(r.Context(), id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+ return
+ }
+ if userID != "" && ag.OwnerID != userID {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can regenerate agent"})
+ return
+ }
+ if ag.Status == "summoning" {
+ writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is already being summoned"})
+ return
+ }
+ if h.summoner == nil {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "summoning not available"})
+ return
+ }
+
+ var req struct {
+ Prompt string `json:"prompt"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if req.Prompt == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "prompt is required"})
+ return
+ }
+
+ // Set status to summoning
+ if err := h.agents.Update(r.Context(), id, map[string]any{"status": "summoning"}); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+
+ go h.summoner.RegenerateAgent(id, ag.Provider, ag.Model, req.Prompt)
+
+ writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": "summoning"})
+}
+
+// extractDescription pulls the description string from other_config JSONB.
+func extractDescription(raw json.RawMessage) string {
+ if len(raw) == 0 {
+ return ""
+ }
+ var cfg map[string]interface{}
+ if json.Unmarshal(raw, &cfg) != nil {
+ return ""
+ }
+ desc, _ := cfg["description"].(string)
+ return desc
+}
+
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
diff --git a/internal/http/summoner.go b/internal/http/summoner.go
new file mode 100644
index 00000000..674873f3
--- /dev/null
+++ b/internal/http/summoner.go
@@ -0,0 +1,263 @@
+package http
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/providers"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/pkg/protocol"
+)
+
+// summoningFiles is the ordered list of context files the LLM should generate.
+var summoningFiles = []string{
+ "SOUL.md",
+ "IDENTITY.md",
+ "AGENTS.md",
+ "TOOLS.md",
+ "HEARTBEAT.md",
+}
+
+// fileTagRe parses content from LLM output.
+var fileTagRe = regexp.MustCompile(`(?s)\s*(.*?)\s*`)
+
+// AgentSummoner generates context files for predefined agents using an LLM.
+// Runs one-shot background calls — no session data, no agent loop.
+type AgentSummoner struct {
+ agents store.AgentStore
+ providerReg *providers.Registry
+ msgBus *bus.MessageBus
+}
+
+// NewAgentSummoner creates a summoner backed by the given stores and provider registry.
+func NewAgentSummoner(agents store.AgentStore, providerReg *providers.Registry, msgBus *bus.MessageBus) *AgentSummoner {
+ return &AgentSummoner{
+ agents: agents,
+ providerReg: providerReg,
+ msgBus: msgBus,
+ }
+}
+
+// SummonAgent generates context files from a natural language description.
+// Meant to be called as a goroutine: go summoner.SummonAgent(...)
+// On success: stores generated files and sets agent status to "active".
+// On failure: keeps template files (already seeded) and sets status to "active".
+func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, description string) {
+ ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
+ defer cancel()
+
+ s.emitEvent(agentID, "started", "", "")
+
+ files, err := s.generateFiles(ctx, providerName, model, s.buildCreatePrompt(description))
+ if err != nil {
+ slog.Warn("summoning: LLM generation failed, falling back to templates",
+ "agent", agentID, "error", err)
+ s.emitEvent(agentID, "failed", "", err.Error())
+ s.setAgentStatus(ctx, agentID, "active")
+ return
+ }
+
+ s.storeFiles(ctx, agentID, files)
+ s.setAgentStatus(ctx, agentID, "active")
+ s.emitEvent(agentID, "completed", "", "")
+
+ slog.Info("summoning: completed", "agent", agentID, "files", len(files))
+}
+
+// RegenerateAgent updates context files based on an edit prompt.
+// Reads existing files, sends them + edit instructions to LLM, stores results.
+// Synchronous — caller should run in goroutine if needed.
+func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, editPrompt string) {
+ ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
+ defer cancel()
+
+ s.emitEvent(agentID, "started", "", "")
+
+ // Read existing files for context
+ existing, err := s.agents.GetAgentContextFiles(ctx, agentID)
+ if err != nil {
+ slog.Warn("summoning: failed to read existing files", "agent", agentID, "error", err)
+ s.emitEvent(agentID, "failed", "", err.Error())
+ s.setAgentStatus(ctx, agentID, "active")
+ return
+ }
+
+ prompt := s.buildEditPrompt(existing, editPrompt)
+
+ files, err := s.generateFiles(ctx, providerName, model, prompt)
+ if err != nil {
+ slog.Warn("summoning: regeneration failed", "agent", agentID, "error", err)
+ s.emitEvent(agentID, "failed", "", err.Error())
+ s.setAgentStatus(ctx, agentID, "active")
+ return
+ }
+
+ s.storeFiles(ctx, agentID, files)
+ s.setAgentStatus(ctx, agentID, "active")
+ s.emitEvent(agentID, "completed", "", "")
+
+ slog.Info("summoning: regeneration completed", "agent", agentID, "files", len(files))
+}
+
+// generateFiles calls the LLM and parses the XML-tagged response into file map.
+func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model, prompt string) (map[string]string, error) {
+ provider, err := s.resolveProvider(providerName)
+ if err != nil {
+ return nil, fmt.Errorf("resolve provider: %w", err)
+ }
+
+ resp, err := provider.Chat(ctx, providers.ChatRequest{
+ Messages: []providers.Message{
+ {Role: "user", Content: prompt},
+ },
+ Model: model,
+ Options: map[string]interface{}{
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("LLM call: %w", err)
+ }
+
+ files := parseFileResponse(resp.Content)
+ if len(files) == 0 {
+ return nil, fmt.Errorf("LLM returned no parseable files (response length: %d)", len(resp.Content))
+ }
+
+ return files, nil
+}
+
+// storeFiles saves generated files to agent_context_files and emits progress events.
+func (s *AgentSummoner) storeFiles(ctx context.Context, agentID uuid.UUID, files map[string]string) {
+ for _, name := range summoningFiles {
+ content, ok := files[name]
+ if !ok || content == "" {
+ continue
+ }
+ if err := s.agents.SetAgentContextFile(ctx, agentID, name, content); err != nil {
+ slog.Warn("summoning: failed to store file", "agent", agentID, "file", name, "error", err)
+ continue
+ }
+ s.emitEvent(agentID, "file_generated", name, "")
+ }
+}
+
+func (s *AgentSummoner) resolveProvider(name string) (providers.Provider, error) {
+ if s.providerReg == nil {
+ return nil, fmt.Errorf("no provider registry")
+ }
+
+ provider, err := s.providerReg.Get(name)
+ if err != nil {
+ // Fallback to first available provider
+ names := s.providerReg.List()
+ if len(names) == 0 {
+ return nil, fmt.Errorf("no providers configured")
+ }
+ provider, err = s.providerReg.Get(names[0])
+ if err != nil {
+ return nil, err
+ }
+ slog.Warn("summoning: provider not found, using fallback", "wanted", name, "using", names[0])
+ }
+ return provider, nil
+}
+
+func (s *AgentSummoner) setAgentStatus(ctx context.Context, agentID uuid.UUID, status string) {
+ if err := s.agents.Update(ctx, agentID, map[string]any{"status": status}); err != nil {
+ slog.Warn("summoning: failed to update agent status", "agent", agentID, "status", status, "error", err)
+ }
+}
+
+func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg string) {
+ if s.msgBus == nil {
+ return
+ }
+ payload := map[string]interface{}{
+ "type": eventType,
+ "agent_id": agentID.String(),
+ }
+ if fileName != "" {
+ payload["file"] = fileName
+ }
+ if errMsg != "" {
+ payload["error"] = errMsg
+ }
+ s.msgBus.Broadcast(bus.Event{
+ Name: protocol.EventAgentSummoning,
+ Payload: payload,
+ })
+}
+
+// buildCreatePrompt constructs the system + user prompt for initial file generation.
+func (s *AgentSummoner) buildCreatePrompt(description string) string {
+ return fmt.Sprintf(`You are setting up a new AI assistant. Based on the description below, generate the content for these context files. Write in the same language as the description.
+
+
+%s
+
+
+Generate each file inside XML tags. Follow the format and spirit of each file type:
+
+- **SOUL.md**: Who the agent IS. Core personality traits, communication style, boundaries, and values. Written in first person, like a character sheet. Not a job description — a soul.
+- **IDENTITY.md**: Quick identity card. Name, Creature (what kind of entity?), Vibe (how they come across), Emoji (signature), Avatar (leave blank).
+- **AGENTS.md**: Operating instructions. How the agent should behave each session, handle memory, manage safety, work in groups. Keep the core structure from the template but personalize for this agent's purpose.
+- **TOOLS.md**: Notes about tools relevant to this agent's role. What environment-specific details matter.
+- **HEARTBEAT.md**: Periodic tasks (if the agent should check things proactively). Leave minimal/empty if not applicable.
+
+
+(generate here)
+
+
+(generate here)
+
+
+(generate here)
+
+
+(generate here)
+
+
+(generate here)
+`, description)
+}
+
+// buildEditPrompt constructs the prompt for editing existing files.
+func (s *AgentSummoner) buildEditPrompt(existing []store.AgentContextFileData, editPrompt string) string {
+ var sb strings.Builder
+ sb.WriteString("You are updating an existing AI assistant's configuration files.\n\nHere are the current files:\n\n\n")
+ for _, f := range existing {
+ if f.Content == "" {
+ continue
+ }
+ fmt.Fprintf(&sb, "\n%s\n\n", f.FileName, f.Content)
+ }
+ sb.WriteString("\n\n")
+ fmt.Fprintf(&sb, "\n%s\n\n\n", editPrompt)
+ sb.WriteString("Generate updated files. Only include files that need changes. Keep the same XML format:\n\n")
+ sb.WriteString("\n(updated content, or omit if unchanged)\n\n")
+ sb.WriteString("...\n")
+ return sb.String()
+}
+
+// parseFileResponse extracts file contents from XML-tagged LLM output.
+func parseFileResponse(content string) map[string]string {
+ files := make(map[string]string)
+ matches := fileTagRe.FindAllStringSubmatch(content, -1)
+ for _, m := range matches {
+ name := strings.TrimSpace(m[1])
+ body := strings.TrimSpace(m[2])
+ if name != "" && body != "" {
+ files[name] = body
+ }
+ }
+ return files
+}
diff --git a/internal/store/pg/channel_instances.go b/internal/store/pg/channel_instances.go
index fe0c08f6..41a372fa 100644
--- a/internal/store/pg/channel_instances.go
+++ b/internal/store/pg/channel_instances.go
@@ -3,6 +3,7 @@ package pg
import (
"context"
"database/sql"
+ "encoding/json"
"fmt"
"log/slog"
"time"
@@ -144,8 +145,18 @@ func (s *PGChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelI
func (s *PGChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
// Encrypt credentials if present
- if credsVal, ok := updates["credentials"]; ok {
- if credsStr, isStr := credsVal.(string); isStr && credsStr != "" && s.encKey != "" {
+ if credsVal, ok := updates["credentials"]; ok && credsVal != nil {
+ var credsStr string
+ switch v := credsVal.(type) {
+ case string:
+ credsStr = v
+ default:
+ // Object/map from JSON — marshal to string for encryption
+ if b, err := json.Marshal(v); err == nil {
+ credsStr = string(b)
+ }
+ }
+ if credsStr != "" && s.encKey != "" {
encrypted, err := crypto.Encrypt(credsStr, s.encKey)
if err != nil {
return fmt.Errorf("encrypt credentials: %w", err)
diff --git a/internal/tools/context_file_interceptor.go b/internal/tools/context_file_interceptor.go
index 60865588..e6f888db 100644
--- a/internal/tools/context_file_interceptor.go
+++ b/internal/tools/context_file_interceptor.go
@@ -101,7 +101,7 @@ func NewContextFileInterceptor(as store.AgentStore, workspace string) *ContextFi
// ReadFile attempts to read a context file from the DB (with cache).
// Routes based on agent type from context:
// - "open": all files per-user → fallback to agent-level
-// - "predefined": only USER.md per-user → all others agent-level
+// - "predefined": USER.md + BOOTSTRAP.md per-user → all others agent-level
//
// Returns (content, true, nil) if handled, or ("", false, nil) if not a context file.
func (b *ContextFileInterceptor) ReadFile(ctx context.Context, path string) (string, bool, error) {
@@ -131,8 +131,8 @@ func (b *ContextFileInterceptor) ReadFile(ctx context.Context, path string) (str
return b.readAgentFile(ctx, agentID, fileName)
}
- // Predefined agent: only USER.md per-user
- if agentType == "predefined" && userID != "" && fileName == "USER.md" {
+ // Predefined agent: USER.md and BOOTSTRAP.md per-user
+ if agentType == "predefined" && userID != "" && (fileName == "USER.md" || fileName == "BOOTSTRAP.md") {
content, handled, err := b.readUserFile(ctx, agentID, userID, fileName)
if err != nil {
return "", handled, err
@@ -262,7 +262,9 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st
// senderID empty = system context (cron, subagent) → fail open
}
- // BOOTSTRAP.md deletion: empty content = first-run completed → delete row
+ // BOOTSTRAP.md deletion: empty content = first-run completed → delete row.
+ // Must come BEFORE the predefined write block so bootstrap completion works
+ // for both open and predefined agents.
if fileName == "BOOTSTRAP.md" && content == "" && userID != "" {
err := b.agentStore.DeleteUserContextFile(ctx, agentID, userID, fileName)
if err == nil {
@@ -271,6 +273,16 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st
return true, err
}
+ // Predefined agent: block writes to shared files (only USER.md allowed per-user).
+ // This prevents any user from modifying the agent's identity/behavior via chat.
+ if agentType == "predefined" && fileName != "USER.md" {
+ return true, fmt.Errorf(
+ "this file (%s) is part of the agent's predefined configuration and cannot be modified through chat. "+
+ "Only the agent owner can edit it from the management dashboard.",
+ fileName,
+ )
+ }
+
// Open agent: all files per-user
if agentType == "open" && userID != "" {
err := b.agentStore.SetUserContextFile(ctx, agentID, userID, fileName, content)
@@ -353,6 +365,22 @@ func (b *ContextFileInterceptor) LoadContextFiles(ctx context.Context, agentID u
Content: content,
})
}
+
+ // Include user-only files not present at agent level
+ // (e.g. BOOTSTRAP.md — seeded per-user for onboarding, not at agent level)
+ agentFileSet := make(map[string]bool, len(agentFiles))
+ for _, f := range agentFiles {
+ agentFileSet[f.FileName] = true
+ }
+ for _, f := range userFiles {
+ if !agentFileSet[f.FileName] && f.Content != "" {
+ result = append(result, bootstrap.ContextFile{
+ Path: f.FileName,
+ Content: f.Content,
+ })
+ }
+ }
+
return result
}
diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go
index 6c7544f0..acb4a1cc 100644
--- a/internal/tools/context_keys.go
+++ b/internal/tools/context_keys.go
@@ -15,6 +15,7 @@ const (
ctxPeerKind toolContextKey = "tool_peer_kind"
ctxSandboxKey toolContextKey = "tool_sandbox_key"
ctxAsyncCB toolContextKey = "tool_async_cb"
+ ctxWorkspace toolContextKey = "tool_workspace"
)
func WithToolChannel(ctx context.Context, channel string) context.Context {
@@ -61,3 +62,12 @@ func ToolAsyncCBFromCtx(ctx context.Context) AsyncCallback {
v, _ := ctx.Value(ctxAsyncCB).(AsyncCallback)
return v
}
+
+func WithToolWorkspace(ctx context.Context, ws string) context.Context {
+ return context.WithValue(ctx, ctxWorkspace, ws)
+}
+
+func ToolWorkspaceFromCtx(ctx context.Context) string {
+ v, _ := ctx.Value(ctxWorkspace).(string)
+ return v
+}
diff --git a/internal/tools/dynamic_tool.go b/internal/tools/dynamic_tool.go
index b6d00a36..8e14741a 100644
--- a/internal/tools/dynamic_tool.go
+++ b/internal/tools/dynamic_tool.go
@@ -58,8 +58,12 @@ func (t *DynamicTool) Execute(ctx context.Context, args map[string]interface{})
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
- // Working directory
- cwd := t.workspace
+ // Working directory — per-user workspace from context, fallback to tool's workspace.
+ // Explicit WorkingDir on the tool definition still overrides everything.
+ cwd := ToolWorkspaceFromCtx(ctx)
+ if cwd == "" {
+ cwd = t.workspace
+ }
if t.def.WorkingDir != "" {
cwd = t.def.WorkingDir
}
diff --git a/internal/tools/edit.go b/internal/tools/edit.go
index 4c6499be..1558f5c3 100644
--- a/internal/tools/edit.go
+++ b/internal/tools/edit.go
@@ -130,8 +130,12 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]interface{}) *Re
return t.executeInSandbox(ctx, path, oldStr, newStr, replaceAll, sandboxKey)
}
- // Host execution
- resolved, err := resolvePath(path, t.workspace, t.restrict)
+ // Host execution — use per-user workspace from context if available (managed mode)
+ workspace := ToolWorkspaceFromCtx(ctx)
+ if workspace == "" {
+ workspace = t.workspace
+ }
+ resolved, err := resolvePath(path, workspace, t.restrict)
if err != nil {
return ErrorResult(err.Error())
}
diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go
index 623d29b3..38ed763a 100644
--- a/internal/tools/filesystem.go
+++ b/internal/tools/filesystem.go
@@ -101,8 +101,12 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{})
return t.executeInSandbox(ctx, path, sandboxKey)
}
- // Host execution
- resolved, err := resolvePathWithAllowed(path, t.workspace, t.restrict, t.allowedPrefixes)
+ // Host execution — use per-user workspace from context if available (managed mode)
+ workspace := ToolWorkspaceFromCtx(ctx)
+ if workspace == "" {
+ workspace = t.workspace
+ }
+ resolved, err := resolvePathWithAllowed(path, workspace, t.restrict, t.allowedPrefixes)
if err != nil {
return ErrorResult(err.Error())
}
diff --git a/internal/tools/filesystem_list.go b/internal/tools/filesystem_list.go
index 3795063c..8b7b7304 100644
--- a/internal/tools/filesystem_list.go
+++ b/internal/tools/filesystem_list.go
@@ -53,8 +53,12 @@ func (t *ListFilesTool) Execute(ctx context.Context, args map[string]interface{}
return t.executeInSandbox(ctx, path, sandboxKey)
}
- // Host execution
- resolved, err := resolvePath(path, t.workspace, t.restrict)
+ // Host execution — use per-user workspace from context if available (managed mode)
+ workspace := ToolWorkspaceFromCtx(ctx)
+ if workspace == "" {
+ workspace = t.workspace
+ }
+ resolved, err := resolvePath(path, workspace, t.restrict)
if err != nil {
return ErrorResult(err.Error())
}
diff --git a/internal/tools/filesystem_write.go b/internal/tools/filesystem_write.go
index 96d97e3c..0674c4c8 100644
--- a/internal/tools/filesystem_write.go
+++ b/internal/tools/filesystem_write.go
@@ -91,8 +91,12 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}
return t.executeInSandbox(ctx, path, content, sandboxKey)
}
- // Host execution
- resolved, err := resolvePath(path, t.workspace, t.restrict)
+ // Host execution — use per-user workspace from context if available (managed mode)
+ workspace := ToolWorkspaceFromCtx(ctx)
+ if workspace == "" {
+ workspace = t.workspace
+ }
+ resolved, err := resolvePath(path, workspace, t.restrict)
if err != nil {
return ErrorResult(err.Error())
}
diff --git a/internal/tools/shell.go b/internal/tools/shell.go
index 4eaf32bd..985b20b8 100644
--- a/internal/tools/shell.go
+++ b/internal/tools/shell.go
@@ -208,7 +208,11 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *Re
}
}
- cwd := t.workingDir
+ // Use per-user workspace from context if available (managed mode), fallback to struct field
+ cwd := ToolWorkspaceFromCtx(ctx)
+ if cwd == "" {
+ cwd = t.workingDir
+ }
if wd, _ := args["working_dir"].(string); wd != "" {
if t.restrict {
resolved, err := resolvePath(wd, t.workingDir, true)
diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go
index 1f78840a..5a02abe7 100644
--- a/pkg/protocol/events.go
+++ b/pkg/protocol/events.go
@@ -20,6 +20,9 @@ const (
EventHeartbeat = "heartbeat"
EventTalkMode = "talk.mode"
+ // Agent summoning events (predefined agent setup via LLM).
+ EventAgentSummoning = "agent.summoning"
+
// Cache invalidation events (internal, not forwarded to WS clients).
EventCacheInvalidate = "cache.invalidate"
)
diff --git a/ui/web/package.json b/ui/web/package.json
index 199af9ee..24736a11 100644
--- a/ui/web/package.json
+++ b/ui/web/package.json
@@ -14,6 +14,7 @@
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "framer-motion": "^12.34.3",
"lucide-react": "^0.468.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
diff --git a/ui/web/src/lib/slug.ts b/ui/web/src/lib/slug.ts
index 02c44c4d..afb273fa 100644
--- a/ui/web/src/lib/slug.ts
+++ b/ui/web/src/lib/slug.ts
@@ -6,7 +6,7 @@ export function slugify(input: string): string {
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-{2,}/g, "-")
- .replace(/^-+|-+$/g, "");
+ .replace(/^-+/g, "");
}
/**
diff --git a/ui/web/src/pages/agents/agent-card.tsx b/ui/web/src/pages/agents/agent-card.tsx
index 402fd4e0..308a741d 100644
--- a/ui/web/src/pages/agents/agent-card.tsx
+++ b/ui/web/src/pages/agents/agent-card.tsx
@@ -38,9 +38,15 @@ export function AgentCard({ agent, onClick }: AgentCardProps) {
{agent.agent_key}
)}
-
- {agent.status}
-
+ {agent.status === "summoning" ? (
+
+ Summoning...
+
+ ) : (
+
+ {agent.status}
+
+ )}
{/* Model info */}
diff --git a/ui/web/src/pages/agents/agent-create-dialog.tsx b/ui/web/src/pages/agents/agent-create-dialog.tsx
index b41edbf1..824c9552 100644
--- a/ui/web/src/pages/agents/agent-create-dialog.tsx
+++ b/ui/web/src/pages/agents/agent-create-dialog.tsx
@@ -9,6 +9,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -21,6 +22,7 @@ import type { AgentData } from "@/types/agent";
import { slugify, isValidSlug } from "@/lib/slug";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
+import { AGENT_PRESETS } from "./agent-presets";
interface AgentCreateDialogProps {
open: boolean;
@@ -35,6 +37,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
const [provider, setProvider] = useState("");
const [model, setModel] = useState("");
const [agentType, setAgentType] = useState<"open" | "predefined">("open");
+ const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
const enabledProviders = providers.filter((p) => p.enabled);
@@ -56,7 +59,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
provider: provider.trim(),
model: model.trim(),
agent_type: agentType,
- status: "active",
+ other_config: description.trim() ? { description: description.trim() } : undefined,
});
onOpenChange(false);
setAgentKey("");
@@ -64,6 +67,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
setProvider("");
setModel("");
setAgentType("open");
+ setDescription("");
} catch {
// error handled upstream
} finally {
@@ -78,7 +82,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
return (