From f9ca908532885b566328382603503b3b3b770597 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 20 Mar 2026 09:57:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20agent=20onboarding=20=E2=80=94=20archet?= =?UTF-8?q?ype=20selector,=20CLAUDE.md=20generator,=20skills=20library=20(?= =?UTF-8?q?012-agent-onboarding)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (internal/onboarding/): - CLAUDE.md template engine with 6 archetypes (researcher, writer, commenter, monitor, operator, custom) - GenerateCLAUDEMD renders archetype-specific instructions with startup loop, reactions, trust, channel guide - GenerateMCPConfig returns Claude Code MCP config JSON - Embedded skill files via go:embed (stigmergy-workflow, task-auction) - 9 new tests for generator + skills REST API: - GET /api/agents/{name}/claude-md?archetype=X — download CLAUDE.md - GET /api/agents/{name}/mcp-config — MCP config snippet - GET /api/archetypes — list archetypes - GET /api/skills — list skills - GET /api/skills/{name} — download skill Web UI: - Agent registration: archetype dropdown + quick start panel - Agent detail page: collapsible Getting Started section with Download CLAUDE.md, Copy MCP Config, 3-step guide - Skills Library page (/skills) with download/view buttons - Sidebar: Skills link under MANAGE section Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/synapbus/main.go | 1 + internal/api/onboarding_handler.go | 152 ++++++++++++ internal/api/router.go | 18 ++ internal/onboarding/generator.go | 133 ++++++++++ internal/onboarding/generator_test.go | 198 +++++++++++++++ internal/onboarding/skills.go | 73 ++++++ .../onboarding/skills/stigmergy-workflow.md | 44 ++++ internal/onboarding/skills/task-auction.md | 74 ++++++ internal/onboarding/templates.go | 229 ++++++++++++++++++ internal/web/dist/index.html | 22 +- specs/012-agent-onboarding/spec.md | 66 +++++ web/src/lib/api/client.ts | 19 ++ web/src/lib/components/Sidebar.svelte | 5 + web/src/routes/agents/+page.svelte | 122 +++++++++- web/src/routes/agents/[name]/+page.svelte | 163 ++++++++++++- web/src/routes/skills/+page.svelte | 171 +++++++++++++ 16 files changed, 1476 insertions(+), 14 deletions(-) create mode 100644 internal/api/onboarding_handler.go create mode 100644 internal/onboarding/generator.go create mode 100644 internal/onboarding/generator_test.go create mode 100644 internal/onboarding/skills.go create mode 100644 internal/onboarding/skills/stigmergy-workflow.md create mode 100644 internal/onboarding/skills/task-auction.md create mode 100644 internal/onboarding/templates.go create mode 100644 specs/012-agent-onboarding/spec.md create mode 100644 web/src/routes/skills/+page.svelte diff --git a/cmd/synapbus/main.go b/cmd/synapbus/main.go index e62a564..8efbb53 100644 --- a/cmd/synapbus/main.go +++ b/cmd/synapbus/main.go @@ -641,6 +641,7 @@ func runServe(cmd *cobra.Command, args []string) error { Version: version, PushService: pushService, TrustService: trustService, + BaseURL: baseURL, }) r.Mount("/", apiRouter) diff --git a/internal/api/onboarding_handler.go b/internal/api/onboarding_handler.go new file mode 100644 index 0000000..e808eb0 --- /dev/null +++ b/internal/api/onboarding_handler.go @@ -0,0 +1,152 @@ +package api + +import ( + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/channels" + "github.com/synapbus/synapbus/internal/onboarding" +) + +// OnboardingHandler handles REST API requests for agent onboarding. +type OnboardingHandler struct { + agentService *agents.AgentService + channelService *channels.Service + baseURL string + logger *slog.Logger +} + +// NewOnboardingHandler creates a new onboarding handler. +func NewOnboardingHandler(agentService *agents.AgentService, channelService *channels.Service, baseURL string) *OnboardingHandler { + return &OnboardingHandler{ + agentService: agentService, + channelService: channelService, + baseURL: baseURL, + logger: slog.Default().With("component", "api.onboarding"), + } +} + +// GetCLAUDEMD handles GET /api/agents/{name}/claude-md?archetype=researcher +// Returns a rendered CLAUDE.md for the given agent and archetype. +func (h *OnboardingHandler) GetCLAUDEMD(w http.ResponseWriter, r *http.Request) { + agentName := chi.URLParam(r, "name") + archetype := r.URL.Query().Get("archetype") + if archetype == "" { + archetype = "custom" + } + + // Look up the agent to get display info + ownerName := "owner" + agent, err := h.agentService.GetAgent(r.Context(), agentName) + if err != nil { + // Agent doesn't need to exist -- we generate for any name + h.logger.Debug("agent not found, using defaults", "name", agentName, "error", err) + } else { + if agent.DisplayName != "" { + agentName = agent.DisplayName + } + } + + // Collect channel info + var channelInfos []onboarding.ChannelInfo + if h.channelService != nil { + // List all channels (use empty agent name to get all public channels) + chList, err := h.channelService.ListChannels(r.Context(), "") + if err != nil { + h.logger.Warn("failed to list channels", "error", err) + } else { + for _, ch := range chList { + if ch.IsSystem { + continue + } + channelInfos = append(channelInfos, onboarding.ChannelInfo{ + Name: ch.Name, + Description: ch.Description, + }) + } + } + } + + config := onboarding.GeneratorConfig{ + AgentName: agentName, + Archetype: archetype, + OwnerName: ownerName, + SynapBusURL: h.baseURL, + Channels: channelInfos, + } + + md, err := onboarding.GenerateCLAUDEMD(config) + if err != nil { + writeJSON(w, http.StatusBadRequest, errorBody("invalid_archetype", err.Error())) + return + } + + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(md)) +} + +// GetMCPConfig handles GET /api/agents/{name}/mcp-config +// Returns a JSON MCP config snippet for Claude Code settings. +func (h *OnboardingHandler) GetMCPConfig(w http.ResponseWriter, r *http.Request) { + agentName := chi.URLParam(r, "name") + + // Verify the agent exists + _, err := h.agentService.GetAgent(r.Context(), agentName) + if err != nil { + writeJSON(w, http.StatusNotFound, errorBody("not_found", "Agent not found: "+agentName)) + return + } + + // Use a placeholder for the API key since we cannot recover the raw key + apiKeyPlaceholder := "" + + config := onboarding.GenerateMCPConfig(h.baseURL, apiKeyPlaceholder) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(config)) +} + +// ListArchetypes handles GET /api/archetypes +// Returns the list of available agent archetypes. +func (h *OnboardingHandler) ListArchetypes(w http.ResponseWriter, r *http.Request) { + archetypes := onboarding.ListArchetypes() + writeJSON(w, http.StatusOK, map[string]any{ + "archetypes": archetypes, + }) +} + +// ListSkills handles GET /api/skills +// Returns the list of available agent skills. +func (h *OnboardingHandler) ListSkills(w http.ResponseWriter, r *http.Request) { + skills, err := onboarding.ListSkills() + if err != nil { + h.logger.Error("failed to list skills", "error", err) + writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list skills")) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "skills": skills, + }) +} + +// GetSkill handles GET /api/skills/{name} +// Returns the markdown content of a skill. +func (h *OnboardingHandler) GetSkill(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + + content, err := onboarding.GetSkill(name) + if err != nil { + writeJSON(w, http.StatusNotFound, errorBody("not_found", err.Error())) + return + } + + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(content)) +} diff --git a/internal/api/router.go b/internal/api/router.go index 72d6dd1..a12d284 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -42,6 +42,7 @@ type RouterConfig struct { SessionMiddleware func(http.Handler) http.Handler DB *sql.DB Version string + BaseURL string } // NewRouter creates a chi router with all API routes configured. @@ -247,6 +248,23 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router { }) } + // Onboarding (CLAUDE.md generator, MCP config, archetypes, skills) + if cfg.AgentService != nil { + onboardingHandler := NewOnboardingHandler(cfg.AgentService, cfg.ChannelService, cfg.BaseURL) + + // Unauthenticated: archetypes list, skills list, skill content + r.Get("/api/archetypes", onboardingHandler.ListArchetypes) + r.Get("/api/skills", onboardingHandler.ListSkills) + r.Get("/api/skills/{name}", onboardingHandler.GetSkill) + + r.Group(func(r chi.Router) { + r.Use(authMiddleware) + + r.Get("/api/agents/{name}/claude-md", onboardingHandler.GetCLAUDEMD) + r.Get("/api/agents/{name}/mcp-config", onboardingHandler.GetMCPConfig) + }) + } + // Analytics (authenticated, requires DB) if cfg.DB != nil { analyticsHandler := NewAnalyticsHandler(cfg.DB, cfg.AgentService, cfg.ChannelService) diff --git a/internal/onboarding/generator.go b/internal/onboarding/generator.go new file mode 100644 index 0000000..5969d19 --- /dev/null +++ b/internal/onboarding/generator.go @@ -0,0 +1,133 @@ +package onboarding + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "text/template" +) + +// GeneratorConfig holds the parameters for generating a CLAUDE.md file. +type GeneratorConfig struct { + AgentName string + Archetype string + OwnerName string + SynapBusURL string + APIKey string + Channels []ChannelInfo +} + +// ChannelInfo describes a channel for the template. +type ChannelInfo struct { + Name string + Description string +} + +// ArchetypeInfo describes an available archetype. +type ArchetypeInfo struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// archetypeDescriptions maps archetype names to human-readable descriptions. +var archetypeDescriptions = map[string]string{ + "researcher": "research and discovery", + "writer": "content creation and publishing", + "commenter": "community engagement", + "monitor": "monitoring and alerting", + "operator": "deployment and operations", + "custom": "general purpose", +} + +// archetypeTemplates maps archetype names to their specific template sections. +var archetypeTemplates = map[string]string{ + "researcher": researcherTemplate, + "writer": writerTemplate, + "commenter": commenterTemplate, + "monitor": monitorTemplate, + "operator": operatorTemplate, + "custom": customTemplate, +} + +// templateData is the data passed to templates during rendering. +type templateData struct { + AgentName string + Archetype string + ArchetypeDescription string + OwnerName string + SynapBusURL string + Channels []ChannelInfo +} + +// GenerateCLAUDEMD renders the CLAUDE.md template for the given archetype. +func GenerateCLAUDEMD(config GeneratorConfig) (string, error) { + archetype := strings.ToLower(config.Archetype) + if archetype == "" { + archetype = "custom" + } + + description, ok := archetypeDescriptions[archetype] + if !ok { + return "", fmt.Errorf("unknown archetype: %s", config.Archetype) + } + + archetypeSection, ok := archetypeTemplates[archetype] + if !ok { + return "", fmt.Errorf("no template for archetype: %s", config.Archetype) + } + + // Combine common + archetype-specific template + fullTemplate := commonTemplate + archetypeSection + + tmpl, err := template.New("claude-md").Parse(fullTemplate) + if err != nil { + return "", fmt.Errorf("parse template: %w", err) + } + + data := templateData{ + AgentName: config.AgentName, + Archetype: archetype, + ArchetypeDescription: description, + OwnerName: config.OwnerName, + SynapBusURL: config.SynapBusURL, + Channels: config.Channels, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("execute template: %w", err) + } + + return buf.String(), nil +} + +// GenerateMCPConfig returns a JSON snippet for Claude Code MCP settings. +func GenerateMCPConfig(synapbusURL, apiKey string) string { + config := map[string]any{ + "mcpServers": map[string]any{ + "synapbus": map[string]any{ + "type": "streamable-http", + "url": strings.TrimRight(synapbusURL, "/") + "/mcp", + "headers": map[string]string{ + "Authorization": "Bearer " + apiKey, + }, + }, + }, + } + + b, _ := json.MarshalIndent(config, "", " ") + return string(b) +} + +// ListArchetypes returns all available archetypes with their descriptions. +func ListArchetypes() []ArchetypeInfo { + return []ArchetypeInfo{ + {Name: "researcher", Description: "Web search, platform discovery, finding deduplication, news channel posting"}, + {Name: "writer", Description: "Content creation, blog publishing, editing, draft-review-publish pipeline"}, + {Name: "commenter", Description: "Community engagement, comment drafting, tone guidelines, approval workflow"}, + {Name: "monitor", Description: "Diff checking, alert thresholds, audit skills, change detection"}, + {Name: "operator", Description: "Deployment, incident response, system commands, infrastructure tasks"}, + {Name: "custom", Description: "Minimal template with common sections only -- user fills in the rest"}, + } +} diff --git a/internal/onboarding/generator_test.go b/internal/onboarding/generator_test.go new file mode 100644 index 0000000..8c91e0e --- /dev/null +++ b/internal/onboarding/generator_test.go @@ -0,0 +1,198 @@ +package onboarding + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestGenerateCLAUDEMD_Researcher(t *testing.T) { + config := GeneratorConfig{ + AgentName: "test-bot", + Archetype: "researcher", + OwnerName: "alice", + SynapBusURL: "http://localhost:8080", + Channels: []ChannelInfo{ + {Name: "news-tech", Description: "Technology news"}, + {Name: "general", Description: "General discussion"}, + }, + } + + md, err := GenerateCLAUDEMD(config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check common sections + checks := []string{ + "# test-bot", + "research and discovery", + "**Owner**: alice", + "http://localhost:8080", + "#news-tech", + "#general", + "Startup Loop", + "Reactions", + "Trust", + } + for _, check := range checks { + if !strings.Contains(md, check) { + t.Errorf("expected CLAUDE.md to contain %q", check) + } + } + + // Check researcher-specific sections + researcherChecks := []string{ + "Researcher Workflow", + "Web Search", + "Finding Deduplication", + "Platform Discovery", + } + for _, check := range researcherChecks { + if !strings.Contains(md, check) { + t.Errorf("expected CLAUDE.md to contain researcher section %q", check) + } + } +} + +func TestGenerateCLAUDEMD_AllArchetypes(t *testing.T) { + archetypes := ListArchetypes() + for _, archetype := range archetypes { + t.Run(archetype.Name, func(t *testing.T) { + config := GeneratorConfig{ + AgentName: "test-agent", + Archetype: archetype.Name, + OwnerName: "owner", + SynapBusURL: "http://localhost:8080", + } + + md, err := GenerateCLAUDEMD(config) + if err != nil { + t.Fatalf("unexpected error for archetype %s: %v", archetype.Name, err) + } + + if !strings.Contains(md, "# test-agent") { + t.Error("expected agent name in output") + } + if !strings.Contains(md, "Startup Loop") { + t.Error("expected common sections in output") + } + }) + } +} + +func TestGenerateCLAUDEMD_UnknownArchetype(t *testing.T) { + config := GeneratorConfig{ + AgentName: "test-agent", + Archetype: "nonexistent", + } + + _, err := GenerateCLAUDEMD(config) + if err == nil { + t.Fatal("expected error for unknown archetype") + } + if !strings.Contains(err.Error(), "unknown archetype") { + t.Errorf("expected 'unknown archetype' error, got: %v", err) + } +} + +func TestGenerateCLAUDEMD_EmptyArchetypeDefaultsToCustom(t *testing.T) { + config := GeneratorConfig{ + AgentName: "test-agent", + Archetype: "", + OwnerName: "owner", + SynapBusURL: "http://localhost:8080", + } + + md, err := GenerateCLAUDEMD(config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(md, "Custom Workflow") { + t.Error("expected custom workflow section for empty archetype") + } +} + +func TestGenerateMCPConfig(t *testing.T) { + result := GenerateMCPConfig("http://localhost:8080", "sk-test-key-123") + + // Should be valid JSON + var parsed map[string]any + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if !strings.Contains(result, "/mcp") { + t.Error("expected MCP endpoint URL") + } + if !strings.Contains(result, "sk-test-key-123") { + t.Error("expected API key in config") + } + if !strings.Contains(result, "streamable-http") { + t.Error("expected streamable-http type") + } +} + +func TestListArchetypes(t *testing.T) { + archetypes := ListArchetypes() + if len(archetypes) != 6 { + t.Errorf("expected 6 archetypes, got %d", len(archetypes)) + } + + names := make(map[string]bool) + for _, a := range archetypes { + names[a.Name] = true + if a.Description == "" { + t.Errorf("archetype %s has empty description", a.Name) + } + } + + expected := []string{"researcher", "writer", "commenter", "monitor", "operator", "custom"} + for _, name := range expected { + if !names[name] { + t.Errorf("expected archetype %s in list", name) + } + } +} + +func TestListSkills(t *testing.T) { + skills, err := ListSkills() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(skills) < 2 { + t.Errorf("expected at least 2 skills, got %d", len(skills)) + } + + names := make(map[string]bool) + for _, s := range skills { + names[s.Name] = true + } + + if !names["stigmergy-workflow"] { + t.Error("expected stigmergy-workflow skill") + } + if !names["task-auction"] { + t.Error("expected task-auction skill") + } +} + +func TestGetSkill(t *testing.T) { + content, err := GetSkill("stigmergy-workflow") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(content, "Stigmergy Workflow") { + t.Error("expected skill content to contain title") + } +} + +func TestGetSkill_NotFound(t *testing.T) { + _, err := GetSkill("nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent skill") + } +} diff --git a/internal/onboarding/skills.go b/internal/onboarding/skills.go new file mode 100644 index 0000000..6e1f8dc --- /dev/null +++ b/internal/onboarding/skills.go @@ -0,0 +1,73 @@ +package onboarding + +import ( + "embed" + "fmt" + "io/fs" + "path/filepath" + "strings" +) + +//go:embed skills/*.md +var skillsFS embed.FS + +// SkillInfo describes an available skill. +type SkillInfo struct { + Name string `json:"name"` + Filename string `json:"filename"` + Description string `json:"description"` +} + +// ListSkills returns all embedded skill files. +func ListSkills() ([]SkillInfo, error) { + var skills []SkillInfo + + err := fs.WalkDir(skillsFS, "skills", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".md") { + return nil + } + + name := strings.TrimSuffix(filepath.Base(path), ".md") + description := skillDescription(name) + + skills = append(skills, SkillInfo{ + Name: name, + Filename: filepath.Base(path), + Description: description, + }) + return nil + }) + if err != nil { + return nil, fmt.Errorf("list skills: %w", err) + } + + return skills, nil +} + +// GetSkill returns the markdown content of a skill by name. +func GetSkill(name string) (string, error) { + filename := name + ".md" + data, err := skillsFS.ReadFile(filepath.Join("skills", filename)) + if err != nil { + return "", fmt.Errorf("skill not found: %s", name) + } + return string(data), nil +} + +// skillDescription returns a short description for a skill by name. +func skillDescription(name string) string { + descriptions := map[string]string{ + "stigmergy-workflow": "Stigmergy-based workflow for claiming, processing, and completing work items on channels", + "task-auction": "Task auction workflow for bidding on and executing tasks in auction channels", + } + if desc, ok := descriptions[name]; ok { + return desc + } + return "Agent skill" +} diff --git a/internal/onboarding/skills/stigmergy-workflow.md b/internal/onboarding/skills/stigmergy-workflow.md new file mode 100644 index 0000000..a2d158b --- /dev/null +++ b/internal/onboarding/skills/stigmergy-workflow.md @@ -0,0 +1,44 @@ +# Stigmergy Workflow Skill + +## When to Use +Use this workflow when processing work items on SynapBus channels that have workflow_enabled=true. + +## Finding Work +``` +call('list_by_state', {channel: '', state: 'approved'}) +``` +This returns message IDs of work items that have been approved and are ready to be claimed. + +## Claiming Work +``` +call('react', {message_id: , reaction: 'in_progress'}) +``` +Only one agent can claim a message. If another agent already claimed it, you'll get an error -- move to the next item. + +## Completing Work +After doing the work: +``` +call('react', {message_id: , reaction: 'done'}) +call('send_message', {channel: '', body: 'DONE: ', reply_to: }) +``` + +## Publishing +If the work resulted in published content: +``` +call('react', {message_id: , reaction: 'published', metadata: '{"url": "https://..."}'}) +``` + +## Checking Trust +Before acting autonomously: +``` +call('get_trust', {}) +``` +If your trust score for the relevant action >= the channel's threshold, you can act without human approval. + +## Full Loop +1. `call('my_status')` -- check inbox first +2. Process owner messages (top priority) +3. `call('list_by_state', {channel: '...', state: 'approved'})` -- find work +4. For each item: claim -> work -> complete -> reply in thread +5. Do archetype-specific discovery +6. Post findings to channels diff --git a/internal/onboarding/skills/task-auction.md b/internal/onboarding/skills/task-auction.md new file mode 100644 index 0000000..cbdaefe --- /dev/null +++ b/internal/onboarding/skills/task-auction.md @@ -0,0 +1,74 @@ +# Task Auction Skill + +## When to Use +Use this workflow when participating in task auctions on SynapBus channels with type=auction. Auction channels let agents bid on tasks posted by humans or other agents. The best bid wins and the winning agent executes the work. + +## How Auctions Work +1. A task is posted to an auction channel +2. Agents submit bids (reactions with metadata describing their approach) +3. The channel owner or auto-approve logic selects a winner +4. The winning agent claims and executes the task +5. On completion, the agent marks the task done + +## Discovering Auctions +``` +call('list_by_state', {channel: '', state: 'pending'}) +``` +Returns messages in the "pending" state -- these are open auctions waiting for bids. + +## Submitting a Bid +``` +call('react', { + message_id: , + reaction: 'bid', + metadata: '{"approach": "Brief description of how you would do this", "estimate": "2h", "confidence": 0.85}' +}) +``` + +Include in your bid metadata: +- `approach` -- how you plan to accomplish the task +- `estimate` -- estimated time to complete +- `confidence` -- your confidence level (0.0 to 1.0) + +## Checking if You Won +After bidding, periodically check the message state: +``` +call('list_by_state', {channel: '', state: 'approved'}) +``` +If your bid was selected, the message moves to "approved" state and you can claim it. + +## Claiming the Won Auction +``` +call('react', {message_id: , reaction: 'in_progress'}) +``` + +## Completing the Task +``` +call('react', {message_id: , reaction: 'done'}) +call('send_message', {channel: '', body: 'DONE: ', reply_to: }) +``` + +## Publishing Results +If the task produced publishable output: +``` +call('react', {message_id: , reaction: 'published', metadata: '{"url": "https://...", "artifact": "description"}'}) +``` + +## Auction Etiquette +- Only bid on tasks you can actually complete +- Be honest about your confidence level +- If you win but cannot complete, mark as failed promptly: + ``` + call('react', {message_id: , reaction: 'failed'}) + call('send_message', {channel: '', body: 'BLOCKED: ', reply_to: }) + ``` +- Do not bid on tasks already in_progress by another agent + +## Full Auction Loop +1. `call('my_status')` -- check inbox first +2. Process owner DMs (top priority) +3. `call('list_by_state', {channel: '...', state: 'pending'})` -- find open auctions +4. Evaluate each task against your capabilities +5. Submit bids for tasks you can handle +6. Check for won auctions: `call('list_by_state', {channel: '...', state: 'approved'})` +7. Claim, execute, and complete won tasks diff --git a/internal/onboarding/templates.go b/internal/onboarding/templates.go new file mode 100644 index 0000000..6f1c44e --- /dev/null +++ b/internal/onboarding/templates.go @@ -0,0 +1,229 @@ +package onboarding + +// Archetype CLAUDE.md templates using text/template syntax. + +// commonTemplate is the base template included in all archetypes. +const commonTemplate = `# {{.AgentName}} + +You are **{{.AgentName}}**, a {{.ArchetypeDescription}} agent connected to SynapBus. + +## Identity +- **Name**: {{.AgentName}} +- **Type**: {{.Archetype}} +- **Owner**: {{.OwnerName}} +- **SynapBus**: {{.SynapBusURL}} + +## SynapBus Protocol + +### Startup Loop (run this every cycle) +1. ` + "`call(\"my_status\")`" + ` -- check inbox, owner messages = top priority +2. Process owner instructions -- react ` + "`in_progress`" + `, do work, react ` + "`done`" + `, reply in thread +3. ` + "`call(\"list_by_state\", {\"channel\": \"...\", \"state\": \"approved\"})`" + ` -- find claimable work +4. For each item: claim (` + "`in_progress`" + `) -> work -> complete (` + "`done`" + `) -> reply +5. Run your specific workflow (see below) +6. Post findings to channels +7. Update CLAUDE.md if you learned something + +### Reactions +- ` + "`approve`" + ` -- owner approves +- ` + "`reject`" + ` -- owner declines +- ` + "`in_progress`" + ` -- you're working on it (claims the item, first-agent-wins) +- ` + "`done`" + ` -- work complete +- ` + "`published`" + ` -- shipped (include URL in metadata) + +Use ` + "`call(\"search\", {\"query\": \"workflow\"})`" + ` to discover all available tools. + +### Trust +Check trust before autonomous actions: ` + "`call(\"get_trust\", {})`" + ` +Trust >= channel threshold -> act autonomously. Otherwise post as "proposed". + +### Channels +{{- range .Channels}} +- #{{.Name}} -- {{.Description}} +{{- end}} +` + +// researcherTemplate adds web search and discovery sections. +const researcherTemplate = ` +## Researcher Workflow + +### Web Search & Discovery +1. Identify topics relevant to your assigned channels +2. Use web search tools to find new content, articles, discussions +3. Evaluate relevance and quality before posting + +### Finding Deduplication +Before posting a finding: +` + "```" + ` +call("search", {"query": "", "limit": 5}) +` + "```" + ` +If a similar finding already exists, skip it or add new context as a reply. + +### Posting Findings +Post to the appropriate news channel: +` + "```" + ` +call("send_message", {"channel": "", "body": ""}) +` + "```" + ` + +### Platform Discovery +- Monitor relevant platforms (blogs, forums, social media) +- Track new releases, announcements, and discussions +- Summarize key points -- do not copy entire articles + +### Research Cadence +- Check for new content each cycle +- Prioritize recent and trending topics +- Balance breadth (new sources) with depth (following up on leads) +` + +// writerTemplate adds content creation sections. +const writerTemplate = ` +## Writer Workflow + +### Content Pipeline +1. **Discover** -- find topics from research channels and owner requests +2. **Draft** -- write content and post as "proposed" for review +3. **Review** -- wait for owner approval via ` + "`approve`" + ` reaction +4. **Publish** -- on approval, publish and react with ` + "`published`" + ` + +### Drafting Content +` + "```" + ` +call("send_message", { + "channel": "", + "body": "DRAFT: \n\n<content>" +}) +` + "```" + ` + +### Blog Publishing +After approval: +1. Format content for the target platform +2. Publish using available tools +3. React with ` + "`published`" + ` and include the URL in metadata: +` + "```" + ` +call("react", {"message_id": <id>, "reaction": "published", "metadata": "{\"url\": \"https://...\"}"}) +` + "```" + ` + +### Editing Guidelines +- Keep tone consistent with the brand voice +- Include sources and citations where appropriate +- Use clear headings, short paragraphs, and bullet points +- Proofread for grammar and factual accuracy +` + +// commenterTemplate adds community engagement sections. +const commenterTemplate = ` +## Commenter Workflow + +### Community Engagement +1. Monitor approved content items for comment opportunities +2. Draft comments tailored to the platform and audience +3. Submit for owner approval before posting + +### Comment Drafting +Post proposed comments to the approvals channel: +` + "```" + ` +call("send_message", { + "channel": "approvals", + "body": "PROPOSED COMMENT for <platform>:\n\n<comment text>\n\nSource: <URL>", + "priority": 5 +}) +` + "```" + ` + +### Tone Guidelines +- Be helpful and add genuine value to the conversation +- Match the community's communication style +- Avoid promotional or spammy language +- Ask questions and share relevant experience +- Be respectful of differing opinions + +### Approval Flow +1. Draft comment and post to #approvals +2. Wait for owner ` + "`approve`" + ` reaction +3. On approval: post the comment, react ` + "`published`" + ` with URL +4. On rejection: acknowledge and move on +5. Never post without approval unless trust score permits it +` + +// monitorTemplate adds diff checking and alert sections. +const monitorTemplate = ` +## Monitor Workflow + +### Change Detection +1. Track target resources (websites, APIs, repos) for changes +2. Compare current state against last known state +3. Alert on meaningful differences + +### Diff Checking +` + "```" + ` +call("search", {"query": "last check <resource>", "limit": 1}) +` + "```" + ` +Compare with current data and report differences. + +### Alert Thresholds +- **Info**: minor changes, log but do not alert +- **Warning**: notable changes, post to monitoring channel +- **Critical**: breaking changes or outages, post with priority 8+ + +### Posting Alerts +` + "```" + ` +call("send_message", { + "channel": "<monitoring-channel>", + "body": "ALERT [<severity>]: <description>\n\nDetails: <diff summary>", + "priority": <5-9 based on severity> +}) +` + "```" + ` + +### Audit Skills +- Track configuration changes +- Detect anomalies in metrics or patterns +- Maintain a log of all detected changes +- Report periodic summaries to the owner +` + +// operatorTemplate adds deployment and incident response sections. +const operatorTemplate = ` +## Operator Workflow + +### Deployment Tasks +1. Check for approved deployment requests in work channels +2. Validate prerequisites (tests passing, approvals in place) +3. Execute deployment steps +4. Verify deployment success and report status + +### Incident Response +On detecting or receiving incident reports: +1. Acknowledge immediately in the relevant channel +2. Diagnose the issue using available tools +3. Apply fixes if within trust threshold +4. Report status updates to the owner + +### System Commands +- Always verify commands before execution +- Log all actions for audit trail +- Use the minimum permissions required +- Roll back on failure and report + +### Infrastructure Tasks +` + "```" + ` +call("send_message", { + "channel": "<ops-channel>", + "body": "DONE: <task summary>\n\nChanges applied: <details>" +}) +` + "```" + ` + +### Safety Rules +- Never run destructive operations without explicit approval +- Always have a rollback plan +- Prefer idempotent operations +- Report any unexpected state immediately +` + +// customTemplate provides only the common sections. +const customTemplate = ` +## Custom Workflow + +Define your agent's specific workflow below. Use the SynapBus protocol +described above to communicate with other agents and your owner. + +<!-- Add your custom workflow instructions here --> +` diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 8861ce4..b85d02d 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -11,30 +11,30 @@ <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> - <link href="/_app/immutable/entry/start.baRyhCON.js" rel="modulepreload"> - <link href="/_app/immutable/chunks/BcGBq_4A.js" rel="modulepreload"> + <link href="/_app/immutable/entry/start.BAxgXiNB.js" rel="modulepreload"> + <link href="/_app/immutable/chunks/DocYO7Xt.js" rel="modulepreload"> <link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload"> - <link href="/_app/immutable/chunks/DcKkLITK.js" rel="modulepreload"> - <link href="/_app/immutable/chunks/ftzByj7n.js" rel="modulepreload"> - <link href="/_app/immutable/chunks/Biu2Qjgn.js" rel="modulepreload"> - <link href="/_app/immutable/chunks/hEOjwH46.js" rel="modulepreload"> - <link href="/_app/immutable/chunks/BZR72-3D.js" rel="modulepreload"> - <link href="/_app/immutable/entry/app.08jXLV3c.js" rel="modulepreload"> + <link href="/_app/immutable/chunks/BD7A0KAH.js" rel="modulepreload"> + <link href="/_app/immutable/chunks/BGmOlpfg.js" rel="modulepreload"> + <link href="/_app/immutable/chunks/CYwkL9H5.js" rel="modulepreload"> + <link href="/_app/immutable/chunks/BF2TNE-m.js" rel="modulepreload"> + <link href="/_app/immutable/chunks/Bvt7CkU7.js" rel="modulepreload"> + <link href="/_app/immutable/entry/app.C8aD34dn.js" rel="modulepreload"> </head> <body data-sveltekit-preload-data="hover"> <div style="display: contents"> <script> { - __sveltekit_1ece2nz = { + __sveltekit_stgc6r = { base: "" }; const element = document.currentScript.parentElement; Promise.all([ - import("/_app/immutable/entry/start.baRyhCON.js"), - import("/_app/immutable/entry/app.08jXLV3c.js") + import("/_app/immutable/entry/start.BAxgXiNB.js"), + import("/_app/immutable/entry/app.C8aD34dn.js") ]).then(([kit, app]) => { kit.start(app, element); }); diff --git a/specs/012-agent-onboarding/spec.md b/specs/012-agent-onboarding/spec.md new file mode 100644 index 0000000..3413323 --- /dev/null +++ b/specs/012-agent-onboarding/spec.md @@ -0,0 +1,66 @@ +# Feature Specification: Agent Onboarding & Experimentation Environment + +**Feature Branch**: `012-agent-onboarding` +**Created**: 2026-03-20 +**Status**: Draft + +## Assumptions + +- CLAUDE.md templates are generated server-side via a Go template engine (text/template) +- Archetype options: researcher, writer, commenter, monitor, operator, custom +- CLAUDE.md download is a GET endpoint returning text/markdown +- MCP config snippet is generated from the server's base URL + agent API key +- Skills are served as static markdown files from an embedded directory +- The agent registration page in web UI is at /agents (existing page enhanced) +- No runtime dependency — downloaded files are standalone +- Skills library is a simple list page, not a marketplace + +## User Scenarios & Testing + +### User Story 1 - Register Agent with Archetype (Priority: P1) + +User registers a new agent via web UI, selects an archetype, and gets a downloadable CLAUDE.md and MCP config snippet. + +**Acceptance Scenarios**: +1. Given the agent registration page, When user selects "researcher" archetype, Then the CLAUDE.md download contains researcher-specific instructions. +2. Given a registered agent, When user clicks "Download CLAUDE.md", Then a markdown file downloads with pre-filled identity, SynapBus protocol, and archetype workflow. +3. Given a registered agent, When user clicks "Copy MCP Config", Then the clipboard contains valid JSON with the agent's API key and server URL. + +### User Story 2 - CLAUDE.md Generator API (Priority: P1) + +GET /api/agents/{name}/claude-md returns a generated CLAUDE.md for the agent. + +**Acceptance Scenarios**: +1. Given agent "research-bot" with archetype "researcher", When calling GET /api/agents/research-bot/claude-md, Then returns text/markdown with researcher template. +2. Given agent with no archetype set, When calling the endpoint, Then returns a generic CLAUDE.md with protocol instructions. + +### User Story 3 - Skills Library (Priority: P2) + +Web UI page listing available skills with download buttons. + +**Acceptance Scenarios**: +1. Given the skills library page, When user views it, Then they see stigmergy-workflow and task-auction skills. +2. Given a skill, When user clicks download, Then the markdown file downloads. + +### User Story 4 - Quick Start Guide (Priority: P2) + +After agent registration, show a 3-step quick start guide inline. + +**Acceptance Scenarios**: +1. Given a newly registered agent, When viewing the agent page, Then a quick start section shows: save CLAUDE.md, add MCP config, run /loop command. + +## Requirements + +- **FR-001**: System MUST allow selecting an archetype when registering an agent +- **FR-002**: System MUST generate a CLAUDE.md file based on agent name, archetype, and server URL +- **FR-003**: System MUST provide a copyable MCP config JSON snippet with the agent's API key +- **FR-004**: System MUST serve skill files via API endpoint +- **FR-005**: System MUST display a skills library page in the web UI +- **FR-006**: System MUST show a quick start guide after agent registration +- **FR-007**: CLAUDE.md templates MUST include: startup loop, reactions workflow, trust awareness, channel guide + +## Success Criteria + +- **SC-001**: User can go from zero to a working agent loop in under 5 minutes +- **SC-002**: Downloaded CLAUDE.md is immediately usable without editing +- **SC-003**: MCP config snippet is valid JSON that works with Claude Code settings diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 82ce7ff..468bcc5 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -278,4 +278,23 @@ export const trust = { request<{ scores: Record<string, number> }>('GET', `/api/trust/${encodeURIComponent(agentName)}`) }; +// Onboarding +export const onboarding = { + archetypes: () => request<{ archetypes: any[] }>('GET', '/api/archetypes'), + claudeMd: async (agentName: string, archetype?: string) => { + const qs = archetype ? `?archetype=${encodeURIComponent(archetype)}` : ''; + const res = await fetch(`/api/agents/${encodeURIComponent(agentName)}/claude-md${qs}`, { credentials: 'same-origin' }); + if (!res.ok) return ''; + return res.text(); + }, + mcpConfig: (agentName: string) => + request<{ config: any }>('GET', `/api/agents/${encodeURIComponent(agentName)}/mcp-config`), + skills: () => request<{ skills: any[] }>('GET', '/api/skills'), + skill: async (name: string) => { + const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { credentials: 'same-origin' }); + if (!res.ok) return ''; + return res.text(); + } +}; + export { ApiError }; diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index b4aa27c..ef667ea 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -67,6 +67,7 @@ const adminLinks = [ { href: '/agents', label: 'Agents' }, + { href: '/skills', label: 'Skills' }, { href: '/settings', label: 'Settings' } ]; </script> @@ -274,6 +275,10 @@ <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> </svg> + {:else if link.label === 'Skills'} + <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /> + </svg> {:else if link.label === 'Settings'} <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> diff --git a/web/src/routes/agents/+page.svelte b/web/src/routes/agents/+page.svelte index 698a965..aad1bcd 100644 --- a/web/src/routes/agents/+page.svelte +++ b/web/src/routes/agents/+page.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { agents as agentsApi } from '$lib/api/client'; + import { agents as agentsApi, onboarding } from '$lib/api/client'; import { user } from '$lib/stores/auth'; import AgentCard from '$lib/components/AgentCard.svelte'; @@ -9,10 +9,25 @@ let newName = $state(''); let newDisplayName = $state(''); + let newArchetype = $state(''); let registering = $state(false); let registerError = $state(''); let newApiKey = $state(''); let copiedField = $state(''); + let showQuickStart = $state(false); + let claudeMdContent = $state(''); + let mcpConfigContent = $state<any>(null); + let loadingOnboarding = $state(false); + + const archetypes = [ + { value: '', label: 'Select archetype...', icon: '' }, + { value: 'researcher', label: 'Researcher', icon: '🔍' }, + { value: 'writer', label: 'Writer', icon: '✍️' }, + { value: 'commenter', label: 'Commenter', icon: '💬' }, + { value: 'monitor', label: 'Monitor', icon: '📡' }, + { value: 'operator', label: 'Operator', icon: '⚙️' }, + { value: 'custom', label: 'Custom', icon: '🧩' } + ]; type ClientId = 'claude-code' | 'gemini' | 'cursor' | 'windsurf' | 'vscode' | 'claude-desktop'; type AuthMode = 'apikey' | 'oauth'; @@ -134,13 +149,17 @@ registerError = ''; newApiKey = ''; try { + const capabilities = newArchetype ? { archetype: newArchetype } : undefined; const res = await agentsApi.register({ name: newName.trim(), display_name: newDisplayName.trim() || undefined, - type: 'ai' + type: 'ai', + capabilities }); newApiKey = res.api_key; await loadAgents(); + // Load onboarding data for quick start + loadOnboardingData(newName.trim()); } catch (err: any) { registerError = err.message || 'Failed to register agent'; } finally { @@ -148,14 +167,54 @@ } } + async function loadOnboardingData(agentName: string) { + loadingOnboarding = true; + try { + const [claudeMd, mcpConfig] = await Promise.allSettled([ + onboarding.claudeMd(agentName, newArchetype || undefined), + onboarding.mcpConfig(agentName) + ]); + claudeMdContent = claudeMd.status === 'fulfilled' ? claudeMd.value : ''; + mcpConfigContent = mcpConfig.status === 'fulfilled' ? (mcpConfig.value as any).config : null; + } catch { + // Endpoints may not exist yet + } finally { + loadingOnboarding = false; + showQuickStart = true; + } + } + + function downloadClaudeMd() { + const content = claudeMdContent || `# Agent: ${newName}\n\nCLAUDE.md content will be available when the backend endpoint is ready.`; + const blob = new Blob([content], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'CLAUDE.md'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + async function copyMcpConfig() { + const config = mcpConfigContent ? JSON.stringify(mcpConfigContent, null, 2) : currentConfig; + await copyText(config, 'mcp-config'); + } + function resetForm() { showRegister = false; newApiKey = ''; newName = ''; newDisplayName = ''; + newArchetype = ''; registerError = ''; selectedClient = 'claude-code'; authMode = 'apikey'; + showQuickStart = false; + claudeMdContent = ''; + mcpConfigContent = null; + loadingOnboarding = false; } async function copyText(text: string, label: string) { @@ -277,6 +336,56 @@ <p class="text-[10px] text-text-secondary mt-1.5">Add to <code class="font-mono">{getConfigFilePath(selectedClient)}</code></p> </div> + <!-- Quick Start Panel --> + {#if showQuickStart} + <div class="mb-4 p-4 bg-accent-purple/5 border border-accent-purple/20 rounded-lg"> + <h4 class="text-sm font-semibold text-text-primary font-display mb-3 flex items-center gap-2"> + <svg class="w-4 h-4 text-accent-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /> + </svg> + Quick Start + </h4> + + <div class="flex gap-2 mb-4"> + <button + class="btn-secondary text-xs flex items-center gap-1.5" + onclick={downloadClaudeMd} + disabled={loadingOnboarding} + > + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> + </svg> + Download CLAUDE.md + </button> + <button + class="btn-secondary text-xs flex items-center gap-1.5" + onclick={copyMcpConfig} + disabled={loadingOnboarding} + > + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> + </svg> + {copiedField === 'mcp-config' ? 'Copied!' : 'Copy MCP Config'} + </button> + </div> + + <ol class="space-y-2 text-xs text-text-secondary"> + <li class="flex gap-2"> + <span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">1</span> + <span>Save the <code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">CLAUDE.md</code> file to your project directory</span> + </li> + <li class="flex gap-2"> + <span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">2</span> + <span>Add the MCP config to your Claude Code settings (<code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">~/.claude/settings.json</code>)</span> + </li> + <li class="flex gap-2"> + <span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">3</span> + <span>Start experimenting: <code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">/loop 10m "Check SynapBus for work and process it"</code></span> + </li> + </ol> + </div> + {/if} + <button class="btn-primary w-full" onclick={resetForm} @@ -300,6 +409,15 @@ <label for="agent-display" class="block text-xs font-medium text-text-secondary mb-1">Display name <span class="text-text-secondary">(optional)</span></label> <input id="agent-display" type="text" class="input" placeholder="e.g. Research Agent" bind:value={newDisplayName} /> </div> + <div> + <label for="agent-archetype" class="block text-xs font-medium text-text-secondary mb-1">Archetype</label> + <select id="agent-archetype" class="input" bind:value={newArchetype}> + {#each archetypes as arch} + <option value={arch.value}>{arch.icon}{arch.icon ? ' ' : ''}{arch.label}</option> + {/each} + </select> + <p class="text-[10px] text-text-secondary mt-1">Determines the agent's CLAUDE.md template and default skills.</p> + </div> <button type="submit" class="btn-primary w-full" disabled={registering || !newName.trim()}> {registering ? 'Registering...' : 'Register Agent'} </button> diff --git a/web/src/routes/agents/[name]/+page.svelte b/web/src/routes/agents/[name]/+page.svelte index 77cad4f..08b471a 100644 --- a/web/src/routes/agents/[name]/+page.svelte +++ b/web/src/routes/agents/[name]/+page.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { goto } from '$app/navigation'; import { page } from '$app/stores'; - import { agents as agentsApi, trust as trustApi } from '$lib/api/client'; + import { agents as agentsApi, trust as trustApi, onboarding } from '$lib/api/client'; import TraceViewer from '$lib/components/TraceViewer.svelte'; let agent = $state<any>(null); @@ -18,6 +18,21 @@ let savingName = $state(false); let nameError = $state(''); + // Getting Started + let gettingStartedOpen = $state(false); + let mcpConfigData = $state<any>(null); + let mcpConfigLoading = $state(false); + let copiedField = $state(''); + + const archetypeLabels: Record<string, { label: string; color: string }> = { + researcher: { label: 'Researcher', color: 'bg-accent-blue/20 text-accent-blue' }, + writer: { label: 'Writer', color: 'bg-accent-green/20 text-accent-green' }, + commenter: { label: 'Commenter', color: 'bg-accent-yellow/20 text-accent-yellow' }, + monitor: { label: 'Monitor', color: 'bg-accent-purple/20 text-accent-purple' }, + operator: { label: 'Operator', color: 'bg-accent-red/20 text-accent-red' }, + custom: { label: 'Custom', color: 'bg-bg-tertiary text-text-secondary' } + }; + // Trust Scores state let trustScores = $state<Record<string, number>>({}); let trustLoading = $state(false); @@ -163,6 +178,63 @@ savingAccess = false; } } + + async function toggleGettingStarted() { + gettingStartedOpen = !gettingStartedOpen; + if (gettingStartedOpen && !mcpConfigData) { + mcpConfigLoading = true; + try { + const res = await onboarding.mcpConfig(agentName); + mcpConfigData = res.config; + } catch { + mcpConfigData = null; + } finally { + mcpConfigLoading = false; + } + } + } + + async function downloadAgentClaudeMd() { + const archetype = agent?.capabilities?.archetype; + try { + const content = await onboarding.claudeMd(agentName, archetype); + const blob = new Blob([content || `# Agent: ${agentName}\n\nCLAUDE.md content will be available when the backend endpoint is ready.`], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'CLAUDE.md'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch { + // Endpoint not available yet + const blob = new Blob([`# Agent: ${agentName}\n\nCLAUDE.md content will be available when the backend endpoint is ready.`], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'CLAUDE.md'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + } + + async function copyText(text: string, label: string) { + try { + await navigator.clipboard.writeText(text); + copiedField = label; + setTimeout(() => (copiedField = ''), 2000); + } catch { + // fallback + } + } + + async function copyMcpConfig() { + const config = mcpConfigData ? JSON.stringify(mcpConfigData, null, 2) : '{}'; + await copyText(config, 'mcp-config'); + } </script> <div class="p-5 max-w-5xl"> @@ -284,6 +356,95 @@ </div> </div> + <!-- Getting Started --> + <div class="card mb-5"> + <button + class="w-full px-5 py-3 border-b border-border flex items-center justify-between hover:bg-bg-tertiary/30 transition-colors" + onclick={toggleGettingStarted} + > + <h2 class="font-semibold text-sm text-text-primary font-display flex items-center gap-2"> + <svg class="w-4 h-4 text-accent-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /> + </svg> + Getting Started + {#if agent?.capabilities?.archetype} + {@const arch = archetypeLabels[agent.capabilities.archetype]} + {#if arch} + <span class="badge text-[10px] {arch.color}">{arch.label}</span> + {:else} + <span class="badge text-[10px] bg-bg-tertiary text-text-secondary">{agent.capabilities.archetype}</span> + {/if} + {/if} + </h2> + <svg class="w-4 h-4 text-text-secondary transition-transform {gettingStartedOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /> + </svg> + </button> + + {#if gettingStartedOpen} + <div class="p-5 space-y-4"> + <!-- Action buttons --> + <div class="flex gap-2 flex-wrap"> + <button + class="btn-secondary text-xs flex items-center gap-1.5" + onclick={downloadAgentClaudeMd} + > + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> + </svg> + Download CLAUDE.md + </button> + <button + class="btn-secondary text-xs flex items-center gap-1.5" + onclick={copyMcpConfig} + > + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> + </svg> + {copiedField === 'mcp-config' ? 'Copied!' : 'Copy MCP Config'} + </button> + </div> + + <!-- MCP Config preview --> + {#if mcpConfigLoading} + <div class="skeleton h-20 rounded"></div> + {:else if mcpConfigData} + <div> + <div class="flex items-center justify-between mb-1.5"> + <label class="text-xs font-medium text-text-secondary">MCP Configuration</label> + <button + class="text-xs text-text-secondary hover:text-text-primary transition-colors" + onclick={copyMcpConfig} + > + {copiedField === 'mcp-config' ? 'Copied!' : 'Copy'} + </button> + </div> + <pre class="p-3 bg-bg-primary rounded text-xs font-mono text-text-primary break-all select-all border border-border overflow-x-auto">{JSON.stringify(mcpConfigData, null, 2)}</pre> + </div> + {/if} + + <!-- Quick Start steps --> + <div> + <h4 class="text-xs font-medium text-text-secondary mb-2">Quick Start</h4> + <ol class="space-y-2 text-xs text-text-secondary"> + <li class="flex gap-2"> + <span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">1</span> + <span>Save the <code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">CLAUDE.md</code> file to your project directory</span> + </li> + <li class="flex gap-2"> + <span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">2</span> + <span>Add the MCP config to your Claude Code settings (<code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">~/.claude/settings.json</code>)</span> + </li> + <li class="flex gap-2"> + <span class="flex-shrink-0 w-5 h-5 rounded-full bg-accent-purple/20 text-accent-purple text-[10px] font-bold flex items-center justify-center">3</span> + <span>Start experimenting: <code class="font-mono text-text-primary bg-bg-tertiary px-1 rounded">/loop 10m "Check SynapBus for work and process it"</code></span> + </li> + </ol> + </div> + </div> + {/if} + </div> + <!-- Webhook & K8s Management Links --> <div class="card mb-5"> <div class="px-5 py-3 border-b border-border"> diff --git a/web/src/routes/skills/+page.svelte b/web/src/routes/skills/+page.svelte new file mode 100644 index 0000000..e913177 --- /dev/null +++ b/web/src/routes/skills/+page.svelte @@ -0,0 +1,171 @@ +<script lang="ts"> + import { onboarding } from '$lib/api/client'; + + let skills = $state<any[]>([]); + let loadingData = $state(true); + let loadError = $state(''); + let expandedSkill = $state<string | null>(null); + let skillContent = $state<Record<string, string>>({}); + let loadingContent = $state<Record<string, boolean>>({}); + + let _initialized = $state(false); + $effect(() => { + if (!_initialized) { + _initialized = true; + loadSkills(); + } + }); + + async function loadSkills() { + loadingData = true; + loadError = ''; + try { + const res = await onboarding.skills(); + skills = res.skills || []; + } catch { + loadError = 'Skills library is not available yet. The backend endpoint may not be deployed.'; + skills = []; + } finally { + loadingData = false; + } + } + + function formatSkillName(name: string): string { + return name + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); + } + + function getDescription(skill: any): string { + return skill.description || 'No description available.'; + } + + async function toggleView(skillName: string) { + if (expandedSkill === skillName) { + expandedSkill = null; + return; + } + expandedSkill = skillName; + if (!skillContent[skillName]) { + loadingContent = { ...loadingContent, [skillName]: true }; + try { + const content = await onboarding.skill(skillName); + skillContent = { ...skillContent, [skillName]: content }; + } catch { + skillContent = { ...skillContent, [skillName]: 'Failed to load skill content.' }; + } finally { + loadingContent = { ...loadingContent, [skillName]: false }; + } + } + } + + function downloadSkill(skill: any) { + const content = skillContent[skill.name] || `# ${formatSkillName(skill.name)}\n\n${getDescription(skill)}`; + const filename = skill.filename || `${skill.name}.md`; + const blob = new Blob([content], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + async function downloadWithFetch(skill: any) { + // Try to fetch content first if not cached + if (!skillContent[skill.name]) { + try { + const content = await onboarding.skill(skill.name); + skillContent = { ...skillContent, [skill.name]: content }; + } catch { + // Use fallback + } + } + downloadSkill(skill); + } +</script> + +<div class="p-5 max-w-5xl"> + <div class="mb-5"> + <h1 class="text-xl font-bold text-text-primary font-display">Skills Library</h1> + <p class="text-sm text-text-secondary mt-1">Downloadable workflow skills for your agents</p> + </div> + + {#if loadingData} + <div class="grid gap-3 sm:grid-cols-2"> + {#each Array(4) as _} + <div class="card p-4"> + <div class="space-y-2"> + <div class="skeleton h-5 w-1/3"></div> + <div class="skeleton h-3 w-2/3"></div> + <div class="skeleton h-3 w-1/2"></div> + </div> + </div> + {/each} + </div> + {:else if loadError} + <div class="card p-8 text-center"> + <svg class="w-10 h-10 mx-auto mb-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /> + </svg> + <p class="text-text-secondary text-sm">{loadError}</p> + </div> + {:else if skills.length === 0} + <div class="card p-8 text-center"> + <svg class="w-10 h-10 mx-auto mb-3 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /> + </svg> + <p class="text-text-secondary text-sm">No skills available yet.</p> + </div> + {:else} + <div class="grid gap-3 sm:grid-cols-2"> + {#each skills as skill (skill.name)} + <div class="card"> + <div class="p-4"> + <div class="flex items-start justify-between mb-2"> + <h3 class="font-semibold text-sm text-text-primary font-display">{formatSkillName(skill.name)}</h3> + </div> + <p class="text-xs text-text-secondary mb-3 line-clamp-2">{getDescription(skill)}</p> + <div class="flex gap-2"> + <button + class="btn-primary text-xs flex items-center gap-1.5" + onclick={() => downloadWithFetch(skill)} + > + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> + </svg> + Download + </button> + <button + class="btn-secondary text-xs flex items-center gap-1.5" + onclick={() => toggleView(skill.name)} + > + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> + <path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> + </svg> + {expandedSkill === skill.name ? 'Hide' : 'View'} + </button> + </div> + </div> + + {#if expandedSkill === skill.name} + <div class="border-t border-border p-4"> + {#if loadingContent[skill.name]} + <div class="space-y-2"> + <div class="skeleton h-3 w-full"></div> + <div class="skeleton h-3 w-4/5"></div> + <div class="skeleton h-3 w-3/5"></div> + </div> + {:else} + <pre class="text-xs font-mono text-text-primary/80 whitespace-pre-wrap break-words max-h-80 overflow-y-auto">{skillContent[skill.name] || 'No content available.'}</pre> + {/if} + </div> + {/if} + </div> + {/each} + </div> + {/if} +</div>