5 Commits
Author SHA1 Message Date
Algis Dumbris 7119827bed Merge branch '012-agent-onboarding' into main
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
2026-03-20 09:58:02 +02:00
Algis DumbrisandClaude Opus 4.6 f9ca908532 feat: agent onboarding — archetype selector, CLAUDE.md generator, skills library (012-agent-onboarding)
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) <noreply@anthropic.com>
2026-03-20 09:57:49 +02:00
Algis DumbrisandClaude Opus 4.6 1b942db80e docs: agent experimentation environment design spec
Three-stage progression: experiment (Claude Code + /loop) → stabilize
(git repo + Agent SDK) → scale (Docker/K8s). SynapBus stays runtime
agnostic — downloadable CLAUDE.md per archetype, MCP config snippet,
skills as optional plugins. No Docker or K8s required for Stage 1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:45:55 +02:00
Algis DumbrisandClaude Opus 4.6 3ae8393537 feat: self-documenting MCP tools, channel type UI, workflow settings panel
MCP tool descriptions: react, unreact, list_by_state, get_trust,
post_task, bid_task now include workflow context so agents discover
the coordination pattern from tool descriptions alone.

Channel creation UI: added channel type selector (standard/blackboard/
auction) and workflow enabled toggle to the create form.

Channel info panel: workflow settings section with toggles for
workflow_enabled, auto_approve, threshold sliders, and stalemate
timeout inputs. Changes apply via PUT /api/channels/{name}/settings.

Agent skill docs: created stigmergy-workflow.md and task-auction.md
reference skills for agent workspaces.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:33:30 +02:00
Algis DumbrisandClaude Opus 4.6 243a5d8a80 feat: StalemateWorker workflow scanning, website docs, searcher refactor
StalemateWorker: new Phase 2 scans workflow-enabled channels for stale
messages in non-terminal states. Sends reminder DMs after
stalemate_remind_after timeout, escalates to #approvals after
stalemate_escalate_after. Deduplication prevents repeat notifications.
7 new tests.

Website: blog post "SynapBus v0.10: Trust Scores, Reactions, and the
Agent Platform Vision". Updated features page with reactions, trust,
and archetypes sections.

Searcher: all 4 agent AGENT.md files updated with universal startup
loop protocol, trust awareness, and stigmergy workflow instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:57:56 +02:00
24 changed files with 2731 additions and 27 deletions
+1
View File
@@ -641,6 +641,7 @@ func runServe(cmd *cobra.Command, args []string) error {
Version: version,
PushService: pushService,
TrustService: trustService,
BaseURL: baseURL,
})
r.Mount("/", apiRouter)
+44
View File
@@ -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: '<channel-name>', 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: <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: <id>, reaction: 'done'})
call('send_message', {channel: '<channel>', body: 'DONE: <summary>', reply_to: <id>})
```
## Publishing
If the work resulted in published content:
```
call('react', {message_id: <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
+74
View File
@@ -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: '<auction-channel>', state: 'pending'})
```
Returns messages in the "pending" state -- these are open auctions waiting for bids.
## Submitting a Bid
```
call('react', {
message_id: <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: '<auction-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: <id>, reaction: 'in_progress'})
```
## Completing the Task
```
call('react', {message_id: <id>, reaction: 'done'})
call('send_message', {channel: '<auction-channel>', body: 'DONE: <summary of deliverables>', reply_to: <id>})
```
## Publishing Results
If the task produced publishable output:
```
call('react', {message_id: <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: <id>, reaction: 'failed'})
call('send_message', {channel: '<channel>', body: 'BLOCKED: <reason>', reply_to: <id>})
```
- 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
@@ -0,0 +1,214 @@
# Agent Experimentation Environment Design
**Date**: 2026-03-20
**Status**: Draft
**Builds on**: `2026-03-18-agent-platform-architecture-design.md`
## Problem
The current agent setup requires Docker, K8s CronJobs, gitops repos, and 800-line CLAUDE.md files before an agent does anything useful. This blocks experimentation. Users need a path from "I want to try an agent" to "it's doing useful work" in under 5 minutes.
## Design Principles
1. **Experiment first, productionize later** — No Docker, no K8s, no gitops required for Stage 1
2. **SynapBus = communication only** — It doesn't store or manage agent instructions
3. **Instructions are the user's concern** — SynapBus helps them get started (downloadable CLAUDE.md) but doesn't own the config
4. **Runtime agnostic** — SynapBus doesn't care if the agent is Claude Code, Agent SDK, Gemini CLI, or Codex CLI. It sees MCP connections.
5. **Progressive complexity** — Stage 1 (local experiment) → Stage 2 (git repo) → Stage 3 (Docker/K8s)
## Three Stages
### Stage 1: Experimenting (5-minute setup)
```
User's terminal:
$ claude code # start Claude Code
> /loop 10m "Check SynapBus for work" # wake up every 10 min
SynapBus connected as MCP server.
User watches messages in web UI.
Edits CLAUDE.md and .claude/skills/ in real-time.
No Docker, no K8s, no gitops.
```
**What the user does:**
1. Opens SynapBus web UI → Agents → Register Agent → gets API key
2. Clicks "Download CLAUDE.md" → saves to their project directory
3. Adds SynapBus MCP config to Claude Code settings
4. Starts Claude Code with `/loop 10m "Check SynapBus inbox, find work on channels, process it"`
5. Watches the agent work in SynapBus web UI
6. Tweaks CLAUDE.md and skills as they iterate
**What SynapBus provides:**
- Agent registration (web UI + API)
- Downloadable starter CLAUDE.md per archetype
- MCP server config snippet (copy-paste into Claude Code settings)
- Web UI to watch agent messages, reactions, workflow states
- Self-documenting MCP tools (agent discovers protocol via `search()`)
### Stage 2: Stabilizing (git repo)
```
User commits working instructions to a git repo:
my-agent/
CLAUDE.md # refined instructions
.claude/skills/ # working skills
.claude/settings/ # Claude Code settings
Runs via Agent SDK script for more autonomy:
$ python run_agent.py
```
**Transition from Stage 1:**
- User has iterated on CLAUDE.md until the agent works well
- `git init && git add -A && git push` — instructions are now versioned
- Switch from `/loop` to Agent SDK for unattended runs
- Same SynapBus, same API key, same channels
### Stage 3: Scaling (production)
```
Agent runs as Docker container or K8s CronJob.
Workspace is a gitops repo (auto-pulled each run).
Trust scores accumulate. StalemateWorker monitors.
```
**Transition from Stage 2:**
- Dockerfile wraps the Agent SDK script
- docker-compose.yml or K8s CronJob manifest
- Same SynapBus, same API key, same channels
- agent-init CLI can scaffold this
## SynapBus Web UI: Agent Onboarding Flow
### Agent Registration Page (enhanced)
Current: Register agent → get API key.
**Add:**
1. **Archetype selector** — "What kind of agent?" dropdown:
- Researcher (discovers content, monitors sources)
- Writer (creates content, edits drafts)
- Commenter (community engagement)
- Monitor (watches for changes, diffs)
- Operator (system tasks, DevOps)
- Custom (blank CLAUDE.md)
2. **Download CLAUDE.md** button — generates a starter CLAUDE.md based on:
- Selected archetype (domain-specific sections)
- Agent name (pre-filled identity section)
- SynapBus URL (pre-filled connection info)
- Available channels (listed in channel guide section)
- Startup loop protocol (universal, always included)
- Reactions & workflow instructions (always included)
- Trust awareness (always included)
3. **MCP Config snippet** — copyable JSON for Claude Code settings:
```json
{
"mcpServers": {
"synapbus": {
"type": "http",
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer <your-api-key>"
}
}
}
}
```
4. **Quick Start guide** — 3 steps shown inline:
```
1. Save CLAUDE.md to your project directory
2. Add the MCP config to Claude Code settings
3. Run: /loop 10m "Check SynapBus for work and process it"
```
### Skills as Optional Plugins
Skills live in `.claude/skills/` in the user's project. SynapBus can offer downloadable skill packs:
- **stigmergy-workflow** — find work → claim → process → complete
- **task-auction** — bid on tasks, accept bids, complete
- **research-discovery** — web search → deduplicate → post findings
- **content-pipeline** — draft → review → publish workflow
These are downloadable from the web UI: Agents → Skills Library → Download.
Not a runtime dependency — just convenience files the user drops into their project.
## Runtime Agnostic Design
SynapBus sees MCP connections. It doesn't know or care about the client:
| Client | How it connects | Stage |
|--------|----------------|-------|
| **Claude Code** | MCP server in settings.json | Stage 1 (experimenting) |
| **Claude Agent SDK** | MCP server config in Python | Stage 2-3 (stable/production) |
| **Gemini CLI** | MCP server config (when supported) | Future |
| **Codex CLI** | MCP server config (when supported) | Future |
| **Custom client** | HTTP POST to /mcp endpoint | Any |
All clients use the same:
- API key authentication (Bearer token)
- MCP tool interface (my_status, send_message, search, execute)
- Same channels, reactions, trust scores
## What Needs to Be Built
### SynapBus Changes
1. **Agent registration page enhancement** — archetype selector, CLAUDE.md download, MCP config snippet, quick start guide
2. **CLAUDE.md generator endpoint** — `GET /api/agents/{name}/claude-md?archetype=researcher` returns generated CLAUDE.md
3. **Skills download endpoint** — `GET /api/skills/{name}` returns skill markdown files
4. **Skills library page** — web UI listing available skills with download buttons
### No Changes Needed
- MCP server (already runtime agnostic)
- Tool descriptions (already self-documenting)
- Reactions, trust, workflows (already working)
- Channel types (standard, blackboard, auction already available)
### Documentation
- Quick Start guide on synapbus.dev: "Your first agent in 5 minutes"
- Stage progression guide: experiment → stabilize → scale
- Video/screencast showing the /loop workflow
## Example: 5-Minute Agent Setup
```bash
# 1. Register agent in SynapBus web UI
# → Download CLAUDE.md (researcher archetype)
# → Copy MCP config
# 2. Create project directory
mkdir my-research-agent
cd my-research-agent
mv ~/Downloads/CLAUDE.md .
mkdir -p .claude/skills
# 3. Add MCP config to Claude Code
# (paste into ~/.claude/settings.json or project settings)
# 4. Start experimenting
claude
> /loop 10m "Check SynapBus for work. Search for MCP security news. Post findings to #news-mcpproxy"
# 5. Watch in SynapBus web UI
# Messages appear in channels, reactions track state
# Tweak CLAUDE.md, add skills, iterate
# 6. When happy, commit to git
git init && git add -A && git commit -m "working agent"
```
## Non-Goals
- SynapBus does NOT manage agent instructions at runtime
- SynapBus does NOT start/stop agents
- SynapBus does NOT require specific client software
- No vendor lock-in — agents can switch from Claude to Gemini without SynapBus changes
+7 -7
View File
@@ -339,7 +339,7 @@ func allActions() []Action {
{
Name: "post_task",
Category: "swarm",
Description: "Post a task to an auction channel for agents to bid on",
Description: "Post a task to an auction channel for agents to bid on. Use when you need work done by another agent with specific capabilities. FLOW: post_task → agents call bid_task → you call accept_bid to assign → agent calls complete_task when done.",
Params: []Param{
{Name: "channel_name", Type: "string", Description: "Name of the auction channel", Required: true},
{Name: "title", Type: "string", Description: "Task title", Required: true},
@@ -358,7 +358,7 @@ func allActions() []Action {
{
Name: "bid_task",
Category: "swarm",
Description: "Submit a bid on an open task in an auction channel",
Description: "Submit a bid on an open task. Include your relevant capabilities and time estimate. The task poster will review bids and accept one. Check list_tasks with status='open' to find tasks you can bid on.",
Params: []Param{
{Name: "task_id", Type: "number", Description: "ID of the task to bid on", Required: true},
{Name: "capabilities", Type: "string", Description: "JSON object describing your relevant capabilities"},
@@ -460,7 +460,7 @@ func allActions() []Action {
{
Name: "react",
Category: "reactions",
Description: "Add or toggle a reaction on a message. Valid reactions: approve, reject, in_progress, done, published. Adding the same reaction again removes it (toggle).",
Description: "Add or toggle a reaction on a message to signal workflow state. Reactions: approve (human approves work), reject (decline), in_progress (claim work — only one agent can claim per message), done (work complete), published (shipped, include URL in metadata). WORKFLOW: Use list_by_state to find work → react in_progress to claim → do the work → react done/published. Toggle: calling same reaction again removes it.",
Params: []Param{
{Name: "message_id", Type: "number", Description: "ID of the message to react to", Required: true},
{Name: "reaction", Type: "string", Description: "Reaction type: approve, reject, in_progress, done, published", Required: true},
@@ -481,7 +481,7 @@ func allActions() []Action {
{
Name: "unreact",
Category: "reactions",
Description: "Remove a specific reaction from a message.",
Description: "Remove a specific reaction. Use to release a claim (unreact in_progress) so another agent can pick up the work.",
Params: []Param{
{Name: "message_id", Type: "number", Description: "ID of the message to remove reaction from", Required: true},
{Name: "reaction", Type: "string", Description: "Reaction type to remove: approve, reject, in_progress, done, published", Required: true},
@@ -497,7 +497,7 @@ func allActions() []Action {
{
Name: "get_reactions",
Category: "reactions",
Description: "Get all reactions on a message and its derived workflow state.",
Description: "Get all reactions and derived workflow state for a message. Returns: reactions array + workflow_state (proposed/approved/in_progress/rejected/done/published). Use to check if work is claimed before attempting to claim it.",
Params: []Param{
{Name: "message_id", Type: "number", Description: "ID of the message to get reactions for", Required: true},
},
@@ -512,7 +512,7 @@ func allActions() []Action {
{
Name: "list_by_state",
Category: "reactions",
Description: "List messages in a channel filtered by workflow state. Valid states: proposed, approved, in_progress, rejected, done, published.",
Description: "List messages in a channel filtered by workflow state. Use to find actionable work: list_by_state with state='approved' finds work ready to be claimed. States: proposed (new, awaiting approval), approved (ready for work), in_progress (claimed by agent), rejected, done, published.",
Params: []Param{
{Name: "channel", Type: "string", Description: "Channel name", Required: true},
{Name: "state", Type: "string", Description: "Workflow state to filter by: proposed, approved, in_progress, rejected, done, published", Required: true},
@@ -530,7 +530,7 @@ func allActions() []Action {
{
Name: "get_trust",
Category: "trust",
Description: "Get trust scores for an agent. Returns a map of action types to trust scores (0.0–1.0). Omit agent_name to get your own scores.",
Description: "Get your trust scores by action type. Trust determines autonomy: higher trust = less human approval needed. Scores increase on human approve (+0.05) and decrease on reject (-0.1). Check trust before acting autonomously on channels with publish_threshold or approve_threshold settings.",
Params: []Param{
{Name: "agent_name", Type: "string", Description: "Agent name to query (defaults to calling agent)"},
},
+152
View File
@@ -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 := "<YOUR_API_KEY>"
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))
}
+18
View File
@@ -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)
+419 -1
View File
@@ -153,11 +153,16 @@ func (w *StalemateWorker) checkStaleMessages(ctx context.Context) {
reminded := w.sendPendingReminders(ctx)
escalated := w.escalatePendingMessages(ctx)
if failed > 0 || reminded > 0 || escalated > 0 {
// Phase 2: Workflow stalemate checks for channel messages
wfReminded, wfEscalated := w.checkWorkflowStalemates(ctx)
if failed > 0 || reminded > 0 || escalated > 0 || wfReminded > 0 || wfEscalated > 0 {
w.logger.Info("stalemate check complete",
"auto_failed", failed,
"reminders_sent", reminded,
"escalations_sent", escalated,
"workflow_reminders", wfReminded,
"workflow_escalations", wfEscalated,
)
}
}
@@ -438,6 +443,419 @@ func (w *StalemateWorker) escalationExists(ctx context.Context, messageID int64)
return count > 0
}
// workflowChannel holds channel info relevant to workflow stalemate checking.
type workflowChannel struct {
ID int64
Name string
StalemateRemindAfter string
StalemateEscalateAfter string
}
// staleWorkflowMsg holds info about a channel message in a stale workflow state.
type staleWorkflowMsg struct {
ID int64
Body string
FromAgent string
ChannelID int64
Channel string
State string
StateAge time.Duration
}
// checkWorkflowStalemates scans workflow-enabled channels for messages stuck in
// non-terminal workflow states (proposed, approved, in_progress) and sends
// reminders to channel members or escalates to #approvals.
func (w *StalemateWorker) checkWorkflowStalemates(ctx context.Context) (reminded int64, escalated int64) {
// Step 1: Find all workflow-enabled channels
channels, err := w.listWorkflowChannels(ctx)
if err != nil {
w.logger.Error("list workflow channels failed", "error", err)
return 0, 0
}
if len(channels) == 0 {
return 0, 0
}
for _, ch := range channels {
remindTimeout, err := parseDurationWithDays(ch.StalemateRemindAfter)
if err != nil || remindTimeout <= 0 {
remindTimeout = 24 * time.Hour // default
}
escalateTimeout, err := parseDurationWithDays(ch.StalemateEscalateAfter)
if err != nil || escalateTimeout <= 0 {
escalateTimeout = 72 * time.Hour // default
}
// Step 2: Find messages in non-terminal workflow states
staleMessages, err := w.findStaleWorkflowMessages(ctx, ch)
if err != nil {
w.logger.Error("find stale workflow messages failed",
"channel", ch.Name,
"error", err,
)
continue
}
for _, msg := range staleMessages {
// Step 3: Check escalation first (longer timeout)
if msg.StateAge >= escalateTimeout {
if w.workflowEscalationExists(ctx, msg.ID) {
continue
}
if w.sendWorkflowEscalation(ctx, msg) {
escalated++
}
continue
}
// Step 4: Check reminder (shorter timeout)
if msg.StateAge >= remindTimeout {
if w.workflowReminderExists(ctx, msg.ID) {
continue
}
r := w.sendWorkflowReminders(ctx, msg, ch.ID)
reminded += r
}
}
}
return reminded, escalated
}
// listWorkflowChannels returns all channels that have workflow_enabled = true.
func (w *StalemateWorker) listWorkflowChannels(ctx context.Context) ([]workflowChannel, error) {
rows, err := w.db.QueryContext(ctx,
`SELECT id, name, stalemate_remind_after, stalemate_escalate_after
FROM channels
WHERE workflow_enabled = 1`)
if err != nil {
return nil, fmt.Errorf("query workflow channels: %w", err)
}
defer rows.Close()
var channels []workflowChannel
for rows.Next() {
var ch workflowChannel
if err := rows.Scan(&ch.ID, &ch.Name, &ch.StalemateRemindAfter, &ch.StalemateEscalateAfter); err != nil {
return nil, fmt.Errorf("scan workflow channel: %w", err)
}
channels = append(channels, ch)
}
return channels, rows.Err()
}
// findStaleWorkflowMessages finds channel messages in non-terminal workflow states
// and computes how long they have been in their current state.
func (w *StalemateWorker) findStaleWorkflowMessages(ctx context.Context, ch workflowChannel) ([]staleWorkflowMsg, error) {
// Get all messages in this channel that could be in a workflow state.
// We fetch messages and their reactions, then compute state in Go.
rows, err := w.db.QueryContext(ctx,
`SELECT m.id, m.body, m.from_agent, m.created_at
FROM messages m
WHERE m.channel_id = ?
AND m.from_agent != 'system'
ORDER BY m.created_at ASC`,
ch.ID,
)
if err != nil {
return nil, fmt.Errorf("query channel messages: %w", err)
}
defer rows.Close()
type chanMsg struct {
ID int64
Body string
FromAgent string
CreatedAt time.Time
}
var msgs []chanMsg
for rows.Next() {
var m chanMsg
if err := rows.Scan(&m.ID, &m.Body, &m.FromAgent, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("scan channel message: %w", err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(msgs) == 0 {
return nil, nil
}
// Batch-fetch reactions for all messages
msgIDs := make([]int64, len(msgs))
for i, m := range msgs {
msgIDs[i] = m.ID
}
reactionsMap, err := w.getReactionsByMessageIDs(ctx, msgIDs)
if err != nil {
return nil, fmt.Errorf("get reactions: %w", err)
}
now := time.Now()
var stale []staleWorkflowMsg
for _, m := range msgs {
reactions := reactionsMap[m.ID]
state := computeWorkflowStateFromReactions(reactions)
// Skip terminal states
if isTerminalWorkflowState(state) {
continue
}
// Determine the "state age": how long since the state was entered.
// If reactions exist, use the most recent reaction's created_at.
// If no reactions (proposed state), use the message's created_at.
stateEnteredAt := m.CreatedAt
if len(reactions) > 0 {
// Find the most recent reaction
for _, r := range reactions {
if r.CreatedAt.After(stateEnteredAt) {
stateEnteredAt = r.CreatedAt
}
}
}
stale = append(stale, staleWorkflowMsg{
ID: m.ID,
Body: m.Body,
FromAgent: m.FromAgent,
ChannelID: ch.ID,
Channel: ch.Name,
State: state,
StateAge: now.Sub(stateEnteredAt),
})
}
return stale, nil
}
// reactionRow holds a raw reaction row for workflow state computation.
type reactionRow struct {
Reaction string
CreatedAt time.Time
}
// getReactionsByMessageIDs fetches reactions for a batch of message IDs.
func (w *StalemateWorker) getReactionsByMessageIDs(ctx context.Context, messageIDs []int64) (map[int64][]reactionRow, error) {
if len(messageIDs) == 0 {
return map[int64][]reactionRow{}, nil
}
placeholders := make([]string, len(messageIDs))
args := make([]any, len(messageIDs))
for i, id := range messageIDs {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(
`SELECT message_id, reaction, created_at
FROM message_reactions
WHERE message_id IN (%s)
ORDER BY created_at ASC`,
strings.Join(placeholders, ","),
)
rows, err := w.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query reactions: %w", err)
}
defer rows.Close()
result := make(map[int64][]reactionRow)
for rows.Next() {
var msgID int64
var r reactionRow
if err := rows.Scan(&msgID, &r.Reaction, &r.CreatedAt); err != nil {
return nil, fmt.Errorf("scan reaction: %w", err)
}
result[msgID] = append(result[msgID], r)
}
return result, rows.Err()
}
// computeWorkflowStateFromReactions derives workflow state from raw reaction rows.
// Mirrors the logic in reactions.ComputeWorkflowState without importing that package.
func computeWorkflowStateFromReactions(reactions []reactionRow) string {
if len(reactions) == 0 {
return "proposed"
}
// Reaction priority (same as reactions.reactionPriority)
priority := map[string]int{
"approve": 2,
"in_progress": 3,
"reject": 4,
"done": 5,
"published": 6,
}
// Reaction-to-state mapping (same as reactions.reactionToState)
toState := map[string]string{
"approve": "approved",
"reject": "rejected",
"in_progress": "in_progress",
"done": "done",
"published": "published",
}
highestPriority := 0
highestState := "proposed"
for _, r := range reactions {
if p, ok := priority[r.Reaction]; ok && p > highestPriority {
highestPriority = p
highestState = toState[r.Reaction]
}
}
return highestState
}
// isTerminalWorkflowState returns true if the state should not trigger stalemate checks.
func isTerminalWorkflowState(state string) bool {
switch state {
case "rejected", "done", "published":
return true
default:
return false
}
}
// sendWorkflowReminders sends DMs to channel members about a stale workflow message.
func (w *StalemateWorker) sendWorkflowReminders(ctx context.Context, msg staleWorkflowMsg, channelID int64) int64 {
// Get channel members
rows, err := w.db.QueryContext(ctx,
`SELECT agent_name FROM channel_members WHERE channel_id = ?`,
channelID,
)
if err != nil {
w.logger.Error("query channel members for workflow reminder failed",
"channel_id", channelID,
"error", err,
)
return 0
}
defer rows.Close()
var members []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
continue
}
members = append(members, name)
}
age := formatAge(msg.StateAge)
truncBody := truncate(msg.Body, 100)
count := int64(0)
for _, member := range members {
body := fmt.Sprintf(
"**STALE**: Message #%d in #%s in '%s' for %s. \"%s\" — @%s",
msg.ID, msg.Channel, msg.State, age, truncBody, msg.FromAgent,
)
_, err := w.msgService.SendMessage(ctx, "system", member, body, SendOptions{
Subject: fmt.Sprintf("workflow-stalemate-reminder:%d", msg.ID),
Priority: 7,
Metadata: fmt.Sprintf(`{"workflow_stalemate_reminder_for":%d}`, msg.ID),
})
if err != nil {
w.logger.Error("send workflow stalemate reminder failed",
"message_id", msg.ID,
"to_agent", member,
"error", err,
)
continue
}
w.logger.Info("sent workflow stalemate reminder",
"message_id", msg.ID,
"channel", msg.Channel,
"state", msg.State,
"to_agent", member,
"age", age,
)
count++
}
return count
}
// sendWorkflowEscalation posts an escalation to #approvals for a stale workflow message.
func (w *StalemateWorker) sendWorkflowEscalation(ctx context.Context, msg staleWorkflowMsg) bool {
approvalsChanID, err := w.channelLookup.GetChannelIDByName(ctx, "approvals")
if err != nil {
w.logger.Warn("cannot escalate workflow stalemate: #approvals channel not found", "error", err)
return false
}
age := formatAge(msg.StateAge)
truncBody := truncate(msg.Body, 100)
body := fmt.Sprintf(
"**STALE**: Message #%d in #%s in '%s' for %s. \"%s\" — @%s",
msg.ID, msg.Channel, msg.State, age, truncBody, msg.FromAgent,
)
_, err = w.msgService.SendMessage(ctx, "system", "", body, SendOptions{
Subject: fmt.Sprintf("workflow-stalemate-escalation:%d", msg.ID),
Priority: 9,
Metadata: fmt.Sprintf(`{"workflow_stalemate_escalation_for":%d}`, msg.ID),
ChannelID: &approvalsChanID,
})
if err != nil {
w.logger.Error("send workflow escalation to #approvals failed",
"message_id", msg.ID,
"channel", msg.Channel,
"error", err,
)
return false
}
w.logger.Info("escalated stale workflow message to #approvals",
"message_id", msg.ID,
"channel", msg.Channel,
"state", msg.State,
"age", age,
)
return true
}
// workflowReminderExists checks if a workflow stalemate reminder already exists for a message.
func (w *StalemateWorker) workflowReminderExists(ctx context.Context, messageID int64) bool {
var count int
err := w.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages
WHERE from_agent = 'system'
AND metadata LIKE ?`,
fmt.Sprintf(`%%"workflow_stalemate_reminder_for":%d%%`, messageID),
).Scan(&count)
if err != nil {
return false
}
return count > 0
}
// workflowEscalationExists checks if a workflow stalemate escalation already exists for a message.
func (w *StalemateWorker) workflowEscalationExists(ctx context.Context, messageID int64) bool {
var count int
err := w.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages
WHERE from_agent = 'system'
AND metadata LIKE ?`,
fmt.Sprintf(`%%"workflow_stalemate_escalation_for":%d%%`, messageID),
).Scan(&count)
if err != nil {
return false
}
return count > 0
}
// truncate truncates a string to maxLen characters, appending "..." if truncated.
func truncate(s string, maxLen int) string {
runes := []rune(s)
+342
View File
@@ -478,3 +478,345 @@ func TestFormatAge(t *testing.T) {
})
}
}
func TestComputeWorkflowStateFromReactions(t *testing.T) {
tests := []struct {
name string
reactions []reactionRow
want string
}{
{"no reactions = proposed", nil, "proposed"},
{"approve only", []reactionRow{{Reaction: "approve"}}, "approved"},
{"in_progress only", []reactionRow{{Reaction: "in_progress"}}, "in_progress"},
{"reject only", []reactionRow{{Reaction: "reject"}}, "rejected"},
{"done only", []reactionRow{{Reaction: "done"}}, "done"},
{"published only", []reactionRow{{Reaction: "published"}}, "published"},
{"approve + in_progress = in_progress (higher priority)", []reactionRow{
{Reaction: "approve"},
{Reaction: "in_progress"},
}, "in_progress"},
{"approve + done = done", []reactionRow{
{Reaction: "approve"},
{Reaction: "done"},
}, "done"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := computeWorkflowStateFromReactions(tt.reactions)
if got != tt.want {
t.Errorf("computeWorkflowStateFromReactions() = %q, want %q", got, tt.want)
}
})
}
}
func TestIsTerminalWorkflowState(t *testing.T) {
tests := []struct {
state string
terminal bool
}{
{"proposed", false},
{"approved", false},
{"in_progress", false},
{"rejected", true},
{"done", true},
{"published", true},
}
for _, tt := range tests {
t.Run(tt.state, func(t *testing.T) {
got := isTerminalWorkflowState(tt.state)
if got != tt.terminal {
t.Errorf("isTerminalWorkflowState(%q) = %v, want %v", tt.state, got, tt.terminal)
}
})
}
}
func TestStalemateWorker_WorkflowReminder(t *testing.T) {
svc, db := newStalemateTestService(t)
ctx := context.Background()
// Create a workflow-enabled channel with short timeouts
_, err := db.Exec(
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
VALUES (10, 'news-test', 'Test news channel', '', 'standard', 0, 0, 'system', 1, '1s', '72h', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
if err != nil {
t.Fatalf("create workflow channel: %v", err)
}
// Add system and sender as members
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'system', 'owner', CURRENT_TIMESTAMP)`)
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'sender', 'member', CURRENT_TIMESTAMP)`)
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'receiver', 'member', CURRENT_TIMESTAMP)`)
// Insert a channel message with old created_at (will be in "proposed" state since no reactions)
oldTime := time.Now().Add(-2 * time.Second)
convResult, err := db.Exec(
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('wf-test', 'sender', ?, ?)`,
oldTime, oldTime,
)
if err != nil {
t.Fatalf("insert conversation: %v", err)
}
convID, _ := convResult.LastInsertId()
channelID := int64(10)
_, err = db.Exec(
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
VALUES (?, 'sender', '', 'Draft blog post about MCP', 5, 'pending', '{}', ?, ?, ?)`,
convID, channelID, oldTime, oldTime,
)
if err != nil {
t.Fatalf("insert channel message: %v", err)
}
// Wait for the timeout to elapse
time.Sleep(10 * time.Millisecond)
config := DefaultStalemateConfig()
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no approvals channel")}
worker := NewStalemateWorker(db, svc, lookup, config)
worker.checkStaleMessages(ctx)
// Verify workflow stalemate reminders were sent to channel members
var count int
err = db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND body LIKE '%STALE%'`,
).Scan(&count)
if err != nil {
t.Fatalf("query workflow reminders: %v", err)
}
// Should have sent reminders to all 3 members (system, sender, receiver)
if count < 1 {
t.Errorf("expected at least 1 workflow reminder, got %d", count)
}
}
func TestStalemateWorker_WorkflowEscalation(t *testing.T) {
svc, db := newStalemateTestService(t)
ctx := context.Background()
// Create a workflow-enabled channel with short escalation timeout
_, err := db.Exec(
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
VALUES (10, 'news-test', 'Test news channel', '', 'standard', 0, 0, 'system', 1, '1s', '1s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
if err != nil {
t.Fatalf("create workflow channel: %v", err)
}
// Create #approvals channel
db.Exec(
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, created_at, updated_at)
VALUES (20, 'approvals', 'Approval queue', '', 'standard', 0, 0, 'system', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (20, 'system', 'owner', CURRENT_TIMESTAMP)`)
// Add members to workflow channel
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'sender', 'member', CURRENT_TIMESTAMP)`)
// Insert a channel message old enough to trigger escalation
oldTime := time.Now().Add(-2 * time.Second)
convResult, _ := db.Exec(
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('wf-esc', 'sender', ?, ?)`,
oldTime, oldTime,
)
convID, _ := convResult.LastInsertId()
channelID := int64(10)
_, err = db.Exec(
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
VALUES (?, 'sender', '', 'Stale proposal needing attention', 5, 'pending', '{}', ?, ?, ?)`,
convID, channelID, oldTime, oldTime,
)
if err != nil {
t.Fatalf("insert channel message: %v", err)
}
time.Sleep(10 * time.Millisecond)
config := DefaultStalemateConfig()
lookup := &stubChannelLookup{channelID: 20}
worker := NewStalemateWorker(db, svc, lookup, config)
worker.checkStaleMessages(ctx)
// Verify escalation was sent to #approvals
var count int
err = db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND channel_id = 20 AND body LIKE '%STALE%'`,
).Scan(&count)
if err != nil {
t.Fatalf("query workflow escalation: %v", err)
}
if count != 1 {
t.Errorf("expected 1 workflow escalation, got %d", count)
}
}
func TestStalemateWorker_WorkflowTerminalStateSkip(t *testing.T) {
svc, db := newStalemateTestService(t)
ctx := context.Background()
// Create a workflow-enabled channel with short timeouts
_, err := db.Exec(
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
VALUES (10, 'news-test', 'Test news channel', '', 'standard', 0, 0, 'system', 1, '1s', '1s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
if err != nil {
t.Fatalf("create workflow channel: %v", err)
}
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'sender', 'member', CURRENT_TIMESTAMP)`)
// Insert a channel message
oldTime := time.Now().Add(-2 * time.Second)
convResult, _ := db.Exec(
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('wf-done', 'sender', ?, ?)`,
oldTime, oldTime,
)
convID, _ := convResult.LastInsertId()
channelID := int64(10)
msgResult, err := db.Exec(
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
VALUES (?, 'sender', '', 'Completed task', 5, 'pending', '{}', ?, ?, ?)`,
convID, channelID, oldTime, oldTime,
)
if err != nil {
t.Fatalf("insert channel message: %v", err)
}
msgID, _ := msgResult.LastInsertId()
// Add a "done" reaction — puts it in terminal state
_, err = db.Exec(
`INSERT INTO message_reactions (message_id, agent_name, reaction, metadata, created_at)
VALUES (?, 'sender', 'done', '{}', ?)`,
msgID, oldTime,
)
if err != nil {
t.Fatalf("insert reaction: %v", err)
}
time.Sleep(10 * time.Millisecond)
config := DefaultStalemateConfig()
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no approvals")}
worker := NewStalemateWorker(db, svc, lookup, config)
worker.checkStaleMessages(ctx)
// Verify NO reminders were sent (message is in terminal "done" state)
var count int
err = db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND body LIKE '%STALE%'`,
).Scan(&count)
if err != nil {
t.Fatalf("query reminders: %v", err)
}
if count != 0 {
t.Errorf("expected 0 reminders for terminal state message, got %d", count)
}
}
func TestStalemateWorker_WorkflowDuplicateReminderPrevention(t *testing.T) {
svc, db := newStalemateTestService(t)
ctx := context.Background()
// Create a workflow-enabled channel with short timeout
_, err := db.Exec(
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
VALUES (10, 'news-test', 'Test', '', 'standard', 0, 0, 'system', 1, '1s', '72h', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
if err != nil {
t.Fatalf("create workflow channel: %v", err)
}
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'receiver', 'member', CURRENT_TIMESTAMP)`)
// Insert a channel message
oldTime := time.Now().Add(-2 * time.Second)
convResult, _ := db.Exec(
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('wf-dup', 'sender', ?, ?)`,
oldTime, oldTime,
)
convID, _ := convResult.LastInsertId()
channelID := int64(10)
_, err = db.Exec(
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
VALUES (?, 'sender', '', 'Needs review', 5, 'pending', '{}', ?, ?, ?)`,
convID, channelID, oldTime, oldTime,
)
if err != nil {
t.Fatalf("insert channel message: %v", err)
}
time.Sleep(10 * time.Millisecond)
config := DefaultStalemateConfig()
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no approvals")}
worker := NewStalemateWorker(db, svc, lookup, config)
// Run twice
worker.checkStaleMessages(ctx)
worker.checkStaleMessages(ctx)
// Verify only one set of reminders was sent (no duplicates)
var count int
err = db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND to_agent = 'receiver' AND body LIKE '%STALE%'`,
).Scan(&count)
if err != nil {
t.Fatalf("query reminders: %v", err)
}
if count != 1 {
t.Errorf("expected 1 reminder (no duplicates), got %d", count)
}
}
func TestStalemateWorker_WorkflowNonWorkflowChannelSkip(t *testing.T) {
svc, db := newStalemateTestService(t)
ctx := context.Background()
// Create a channel with workflow DISABLED
_, err := db.Exec(
`INSERT INTO channels (id, name, description, topic, type, is_private, is_system, created_by, workflow_enabled, stalemate_remind_after, stalemate_escalate_after, created_at, updated_at)
VALUES (10, 'general', 'General', '', 'standard', 0, 0, 'system', 0, '1s', '1s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
if err != nil {
t.Fatalf("create channel: %v", err)
}
db.Exec(`INSERT INTO channel_members (channel_id, agent_name, role, joined_at) VALUES (10, 'sender', 'member', CURRENT_TIMESTAMP)`)
// Insert a channel message
oldTime := time.Now().Add(-2 * time.Second)
convResult, _ := db.Exec(
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('no-wf', 'sender', ?, ?)`,
oldTime, oldTime,
)
convID, _ := convResult.LastInsertId()
channelID := int64(10)
db.Exec(
`INSERT INTO messages (conversation_id, from_agent, to_agent, body, priority, status, metadata, channel_id, created_at, updated_at)
VALUES (?, 'sender', '', 'No workflow here', 5, 'pending', '{}', ?, ?, ?)`,
convID, channelID, oldTime, oldTime,
)
time.Sleep(10 * time.Millisecond)
config := DefaultStalemateConfig()
lookup := &stubChannelLookup{channelID: 0, err: fmt.Errorf("no approvals")}
worker := NewStalemateWorker(db, svc, lookup, config)
worker.checkStaleMessages(ctx)
// Verify NO reminders — channel is not workflow-enabled
var count int
err = db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE from_agent = 'system' AND body LIKE '%STALE%'`,
).Scan(&count)
if err != nil {
t.Fatalf("query reminders: %v", err)
}
if count != 0 {
t.Errorf("expected 0 reminders for non-workflow channel, got %d", count)
}
}
+133
View File
@@ -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"},
}
}
+198
View File
@@ -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")
}
}
+73
View File
@@ -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"
}
@@ -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: '<channel-name>', 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: <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: <id>, reaction: 'done'})
call('send_message', {channel: '<channel>', body: 'DONE: <summary>', reply_to: <id>})
```
## Publishing
If the work resulted in published content:
```
call('react', {message_id: <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
@@ -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: '<auction-channel>', state: 'pending'})
```
Returns messages in the "pending" state -- these are open auctions waiting for bids.
## Submitting a Bid
```
call('react', {
message_id: <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: '<auction-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: <id>, reaction: 'in_progress'})
```
## Completing the Task
```
call('react', {message_id: <id>, reaction: 'done'})
call('send_message', {channel: '<auction-channel>', body: 'DONE: <summary of deliverables>', reply_to: <id>})
```
## Publishing Results
If the task produced publishable output:
```
call('react', {message_id: <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: <id>, reaction: 'failed'})
call('send_message', {channel: '<channel>', body: 'BLOCKED: <reason>', reply_to: <id>})
```
- 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
+229
View File
@@ -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": "<your finding summary>", "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": "<news-channel>", "body": "<finding with source URL>"})
` + "```" + `
### 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": "<content-channel>",
"body": "DRAFT: <title>\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 -->
`
+11 -11
View File
@@ -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.DrNjyUiQ.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CVNqI7da.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/C7XpcGuz.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BAuKwN5S.js" rel="modulepreload">
<link href="/_app/immutable/chunks/B_Y_DuQO.js" rel="modulepreload">
<link href="/_app/immutable/chunks/rMDGPMLC.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CghLeKxs.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.MzJRUHQS.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_12fzvv6 = {
__sveltekit_stgc6r = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.DrNjyUiQ.js"),
import("/_app/immutable/entry/app.MzJRUHQS.js")
import("/_app/immutable/entry/start.BAxgXiNB.js"),
import("/_app/immutable/entry/app.C8aD34dn.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+66
View File
@@ -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
+29 -1
View File
@@ -113,7 +113,16 @@ export const channels = {
messages: (name: string, limit?: number) => {
const qs = limit ? `?limit=${limit}` : '';
return request<{ messages: any[]; total: number }>('GET', `/api/channels/${encodeURIComponent(name)}/messages${qs}`);
}
},
updateSettings: (name: string, settings: {
workflow_enabled?: boolean;
auto_approve?: boolean;
publish_threshold?: number;
approve_threshold?: number;
stalemate_remind_after?: string;
stalemate_escalate_after?: string;
}) =>
request<{ channel: any }>('PUT', `/api/channels/${encodeURIComponent(name)}/settings`, settings)
};
// Dead Letters
@@ -269,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 };
+5
View File
@@ -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" />
+120 -2
View File
@@ -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>
+162 -1
View File
@@ -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">
+36 -4
View File
@@ -8,6 +8,8 @@
let newName = $state('');
let newDescription = $state('');
let newIsPrivate = $state(false);
let newType = $state('standard');
let newWorkflowEnabled = $state(false);
let creating = $state(false);
let createError = $state('');
@@ -43,11 +45,22 @@
await channelsApi.create({
name: newName.trim(),
description: newDescription.trim(),
is_private: newIsPrivate
is_private: newIsPrivate,
type: newType
});
// Enable workflow if requested (separate settings call)
if (newWorkflowEnabled) {
try {
await channelsApi.updateSettings(newName.trim(), { workflow_enabled: true });
} catch {
// Channel created but workflow toggle failed -- non-fatal
}
}
newName = '';
newDescription = '';
newIsPrivate = false;
newType = 'standard';
newWorkflowEnabled = false;
showCreate = false;
await loadChannels();
} catch (err: any) {
@@ -81,10 +94,24 @@
<div class="space-y-3">
<input type="text" class="input" placeholder="Channel name (e.g. research-findings)" bind:value={newName} />
<input type="text" class="input" placeholder="Description (optional)" bind:value={newDescription} />
<div>
<label class="block text-xs text-text-secondary mb-1" for="channel-type">Channel Type</label>
<select id="channel-type" bind:value={newType} class="input">
<option value="standard">Standard</option>
<option value="blackboard">Blackboard</option>
<option value="auction">Auction</option>
</select>
</div>
<label class="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
<input type="checkbox" bind:checked={newIsPrivate} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
Private channel (invite-only)
</label>
{#if newType !== 'auction'}
<label class="flex items-center gap-2 text-sm text-text-secondary cursor-pointer">
<input type="checkbox" bind:checked={newWorkflowEnabled} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
Enable workflow reactions
</label>
{/if}
<button type="submit" class="btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create'}
</button>
@@ -121,9 +148,14 @@
<p class="text-xs text-text-secondary truncate mt-0.5">{ch.description}</p>
{/if}
</div>
<span class="badge bg-bg-tertiary text-text-secondary flex-shrink-0 ml-3">
{ch.member_count} members
</span>
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
{#if ch.type && ch.type !== 'standard'}
<span class="badge bg-accent-purple/20 text-accent-purple text-[10px]">{ch.type}</span>
{/if}
<span class="badge bg-bg-tertiary text-text-secondary">
{ch.member_count} members
</span>
</div>
</div>
</a>
{/each}
+109
View File
@@ -29,6 +29,10 @@
let uploading = $state(false);
let fileInputEl: HTMLInputElement;
// Workflow settings state
let settingsSaving = $state(false);
let settingsError = $state('');
function triggerFileInput() { fileInputEl?.click(); }
async function handleFileSelected(e: Event) {
@@ -218,6 +222,19 @@
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
async function updateSetting(settings: Record<string, any>) {
settingsSaving = true;
settingsError = '';
try {
const res = await channelsApi.updateSettings(channelName, settings);
channel = res.channel;
} catch (err: any) {
settingsError = err.message || 'Failed to update settings';
} finally {
settingsSaving = false;
}
}
let isMember = $derived(
members.some(m => agentList.some(a => a.name === m.agent_name))
);
@@ -459,6 +476,98 @@
</div>
{/if}
<!-- Workflow Settings -->
{#if channel && channel.type !== 'auction'}
<div class="border-t border-border pt-3 mt-3">
<h4 class="text-xs font-medium text-text-secondary mb-2">Workflow Settings</h4>
{#if settingsError}
<div class="mb-2 px-2 py-1.5 bg-accent-red/10 rounded text-[11px] text-accent-red">{settingsError}</div>
{/if}
<div class="space-y-2.5">
<label class="flex items-center justify-between text-xs cursor-pointer">
<span class="text-text-primary">Workflow enabled</span>
<input
type="checkbox"
checked={channel.workflow_enabled}
disabled={settingsSaving}
onchange={(e) => updateSetting({ workflow_enabled: (e.target as HTMLInputElement).checked })}
class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green"
/>
</label>
{#if channel.workflow_enabled}
<label class="flex items-center justify-between text-xs cursor-pointer">
<span class="text-text-primary">Auto-approve</span>
<input
type="checkbox"
checked={channel.auto_approve}
disabled={settingsSaving}
onchange={(e) => updateSetting({ auto_approve: (e.target as HTMLInputElement).checked })}
class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green"
/>
</label>
<div>
<label class="block text-xs text-text-primary mb-1" for="publish-threshold">
Publish threshold
<span class="text-text-secondary ml-1">{channel.publish_threshold ?? 0}</span>
</label>
<input
id="publish-threshold"
type="range"
min="0"
max="1"
step="0.05"
value={channel.publish_threshold ?? 0}
disabled={settingsSaving}
onchange={(e) => updateSetting({ publish_threshold: parseFloat((e.target as HTMLInputElement).value) })}
class="w-full h-1.5 bg-bg-tertiary rounded-lg appearance-none cursor-pointer accent-accent-green"
/>
</div>
<div>
<label class="block text-xs text-text-primary mb-1" for="approve-threshold">
Approve threshold
<span class="text-text-secondary ml-1">{channel.approve_threshold ?? 0}</span>
</label>
<input
id="approve-threshold"
type="range"
min="0"
max="1"
step="0.05"
value={channel.approve_threshold ?? 0}
disabled={settingsSaving}
onchange={(e) => updateSetting({ approve_threshold: parseFloat((e.target as HTMLInputElement).value) })}
class="w-full h-1.5 bg-bg-tertiary rounded-lg appearance-none cursor-pointer accent-accent-green"
/>
</div>
<div>
<label class="block text-xs text-text-primary mb-1" for="stalemate-remind">Stalemate remind after</label>
<input
id="stalemate-remind"
type="text"
value={channel.stalemate_remind_after || ''}
disabled={settingsSaving}
placeholder="e.g. 24h, 7d"
onchange={(e) => updateSetting({ stalemate_remind_after: (e.target as HTMLInputElement).value })}
class="input text-xs w-full"
/>
</div>
<div>
<label class="block text-xs text-text-primary mb-1" for="stalemate-escalate">Stalemate escalate after</label>
<input
id="stalemate-escalate"
type="text"
value={channel.stalemate_escalate_after || ''}
disabled={settingsSaving}
placeholder="e.g. 72h, 14d"
onchange={(e) => updateSetting({ stalemate_escalate_after: (e.target as HTMLInputElement).value })}
class="input text-xs w-full"
/>
</div>
{/if}
</div>
</div>
{/if}
<div class="border-t border-border pt-3 mt-3">
<h4 class="text-xs font-medium text-text-secondary mb-2">Members ({members.length})</h4>
{#if members.length === 0}
+171
View File
@@ -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>