From 609b0d2e7e9261e6c0f0855eb0b5875c96e8311e Mon Sep 17 00:00:00 2001 From: Brendan Playford <34052452+teslashibe@users.noreply.github.com> Date: Sat, 9 May 2026 10:52:18 -0700 Subject: [PATCH 1/9] feat(skills): backend pipeline for Anthropic Skills upload + sync closes #35 (backend slice) - internal/skills: Service wraps Anthropic Beta.Skills (List, Upload, Delete) and persists skill_id/name/description/version in Postgres. UploadDir parses SKILL.md frontmatter, walks the folder, preserves relative paths via a namedReader so the multipart upload matches what the API expects. - internal/skills/zip.go: rejects path-traversal entries and zips without exactly one top-level folder + SKILL.md. - cmd/skills-sync: CLI driven by SKILLS_SOURCES that bulk-uploads every SKILL.md folder it finds; idempotent via UNIQUE(name) + UPSERT. - internal/agent/provision.go: SkillIDsFn opt; per-user agents now attach the agent toolset (bash/code-execution) alongside their MCP toolset only when at least one skill is configured. - cmd/provision/main.go: optional SKILLS_AGENT_IDS env to attach skills at bootstrap. - 00005_skills.sql migration with UNIQUE(name) + index on anthropic_skill_id. - /api/skills CRUD wired in main.go; Makefile target skills-sync; SKILLS_SOURCES + SKILLS_AGENT_IDS in .env.example. Co-authored-by: Cursor --- .env.example | 11 + Makefile | 4 + backend/cmd/provision/main.go | 15 + backend/cmd/server/main.go | 18 +- backend/cmd/skills-sync/main.go | 59 ++++ backend/go.mod | 2 +- backend/internal/agent/provision.go | 67 +++- .../internal/db/migrations/00005_skills.sql | 30 ++ backend/internal/skills/handler.go | 107 ++++++ backend/internal/skills/skills.go | 323 ++++++++++++++++++ backend/internal/skills/zip.go | 74 ++++ 11 files changed, 699 insertions(+), 11 deletions(-) create mode 100644 backend/cmd/skills-sync/main.go create mode 100644 backend/internal/db/migrations/00005_skills.sql create mode 100644 backend/internal/skills/handler.go create mode 100644 backend/internal/skills/skills.go create mode 100644 backend/internal/skills/zip.go diff --git a/.env.example b/.env.example index 335aaff..f4e371d 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,17 @@ ANTHROPIC_API_KEY= ANTHROPIC_AGENT_ID= ANTHROPIC_ENVIRONMENT_ID= +# Skills (optional). Comma-separated list of directories whose immediate +# children are skill folders (each containing a SKILL.md at root). Used +# by `make skills-sync` to bulk-upload skills to Anthropic. Example: +# SKILLS_SOURCES=~/.claude/skills,~/.cursor/skills +SKILLS_SOURCES= + +# SKILLS_AGENT_IDS (optional) attaches custom skill IDs at bootstrap +# (cmd/provision). Most setups leave this empty and let the runtime +# per-user provisioner pick up whatever the skills service has uploaded. +# SKILLS_AGENT_IDS=skill_abc,skill_def + AGENT_SYSTEM_PROMPT=You are a helpful assistant. AGENT_RUN_RATE_LIMIT=10 AGENT_RUN_RATE_WINDOW_SECONDS=60 diff --git a/Makefile b/Makefile index 9733647..78f3113 100644 --- a/Makefile +++ b/Makefile @@ -97,6 +97,10 @@ token: seed ## Alias for seed managed-agents-provision: ## Create Anthropic Agent + Environment (run once, store IDs in .env) cd backend && go run ./cmd/provision +skills-sync: ## Upload every SKILL.md folder under $$SKILLS_SOURCES to Anthropic + @set -a; source backend/.env 2>/dev/null || true; set +a; \ + cd backend && go run ./cmd/skills-sync + clean: ## Remove generated artifacts $(COMPOSE) down -v rm -rf backend/bin mobile/node_modules mobile/.expo diff --git a/backend/cmd/provision/main.go b/backend/cmd/provision/main.go index 9d59ac8..4837e3c 100644 --- a/backend/cmd/provision/main.go +++ b/backend/cmd/provision/main.go @@ -31,6 +31,20 @@ func main() { client := anthropic.NewClient(option.WithAPIKey(apiKey)) ctx := context.Background() + // SKILLS_AGENT_IDS=skill_abc,skill_def attaches custom skills at + // bootstrap time. Optional — most forks use cmd/skills-sync to + // upload skills after the agent exists, then rely on the runtime + // per-user provisioner (internal/agent/provision.go) to attach them + // to lazily-created per-user agents. + var skillParams []anthropic.BetaManagedAgentsSkillParamsUnion + if raw := strings.TrimSpace(os.Getenv("SKILLS_AGENT_IDS")); raw != "" { + for _, id := range strings.Split(raw, ",") { + if id = strings.TrimSpace(id); id != "" { + skillParams = append(skillParams, anthropic.BetaManagedAgentsSkillParamsOfCustom(id)) + } + } + } + fmt.Println("Creating Anthropic Agent...") agent, err := client.Beta.Agents.New(ctx, anthropic.BetaAgentNewParams{ Name: "agent-setup", @@ -43,6 +57,7 @@ func main() { Type: anthropic.BetaManagedAgentsAgentToolset20260401ParamsTypeAgentToolset20260401, }, }}, + Skills: skillParams, }) if err != nil { log.Fatalf("create agent: %v", err) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 583630a..56fdb30 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -32,6 +32,7 @@ import ( "github.com/teslashibe/agent-setup/backend/internal/mcp/platforms" "github.com/teslashibe/agent-setup/backend/internal/brand" "github.com/teslashibe/agent-setup/backend/internal/notifications" + "github.com/teslashibe/agent-setup/backend/internal/skills" "github.com/teslashibe/agent-setup/backend/internal/teams" "github.com/teslashibe/agent-setup/backend/internal/uploads" ) @@ -69,6 +70,11 @@ func main() { log.Fatalf("agent: %v", err) } + // Skills are optional: when ANTHROPIC_API_KEY is unset (covered by + // the agent service check above) or no skills have been uploaded + // yet, the service still safely returns an empty list. + skillsSvc := skills.NewService(agentSvc.Client(), pool) + app := fiber.New(fiber.Config{ AppName: "Claude Agent Go", StreamRequestBody: true, @@ -153,6 +159,11 @@ func main() { invitesH.MountPublicRoutes(app, cfg.MobileAppScheme) } + // Skills CRUD: workspace-wide, just JWT-required (no team scope — + // Anthropic skills are organization-wide so per-team isolation + // would be an illusion at this layer). + skills.NewHandler(skillsSvc).Mount(api) + // Agent routes are team-scoped. RequireTeam reads X-Team-ID (or falls // back to the caller's personal team) and stamps team_id + team_role. agentGroup := api.Group("", teamMW.RequireTeam()) @@ -201,7 +212,7 @@ func main() { cfg.NotificationsDefaultPageSize, cfg.NotificationsMaxPageSize) } - if err := mountMCP(app, api, authMW, cfg, pool, magicSvc, agentSvc, notifSvc); err != nil { + if err := mountMCP(app, api, authMW, cfg, pool, magicSvc, agentSvc, notifSvc, skillsSvc); err != nil { log.Fatalf("mcp: %v", err) } @@ -274,6 +285,7 @@ func mountMCP( magicSvc *magiclink.Service, agentSvc *agent.Service, notifSvc *notifications.Service, + skillsSvc *skills.Service, ) error { if cfg.CredentialsEncryptionKey == "" { log.Printf("mcp: CREDENTIALS_ENCRYPTION_KEY not set — MCP routes and per-user provisioner disabled") @@ -321,7 +333,9 @@ func mountMCP( if err != nil { return fmt.Errorf("mcp endpoint factory: %w", err) } - provOpts := agent.ProvisionerOptions{} + provOpts := agent.ProvisionerOptions{ + SkillIDs: skillsSvc.AnthropicIDs, + } if cfg.NotificationsEnabled { provOpts.SystemPrompt = agent.NotificationsSystemPrompt() } diff --git a/backend/cmd/skills-sync/main.go b/backend/cmd/skills-sync/main.go new file mode 100644 index 0000000..33fd5f9 --- /dev/null +++ b/backend/cmd/skills-sync/main.go @@ -0,0 +1,59 @@ +// skills-sync walks every directory in $SKILLS_SOURCES (comma- +// separated), finds folders containing a SKILL.md, uploads each to +// Anthropic via the Beta Skills API, and persists the resulting IDs +// in the skills table. Idempotent: re-running updates existing rows. +// +// make skills-sync +// # or, ad-hoc: +// SKILLS_SOURCES=~/.claude/skills,~/.cursor/skills go run ./cmd/skills-sync +package main + +import ( + "context" + "log" + "os" + "strings" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" + + "github.com/teslashibe/agent-setup/backend/internal/skills" +) + +func main() { + _ = godotenv.Load() + apiKey := strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) + if apiKey == "" { + log.Fatal("ANTHROPIC_API_KEY is required") + } + dbURL := strings.TrimSpace(os.Getenv("DATABASE_URL")) + if dbURL == "" { + log.Fatal("DATABASE_URL is required") + } + sourcesEnv := strings.TrimSpace(os.Getenv("SKILLS_SOURCES")) + if sourcesEnv == "" { + log.Fatal("SKILLS_SOURCES is required (comma-separated list of directories containing skill folders)") + } + sources := strings.Split(sourcesEnv, ",") + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + log.Fatalf("db: %v", err) + } + defer pool.Close() + + client := anthropic.NewClient(option.WithAPIKey(apiKey)) + svc := skills.NewService(client, pool) + + uploaded, errs := svc.SyncDirs(ctx, sources, func(f string, a ...any) { log.Printf(f, a...) }) + log.Printf("done: %d uploaded, %d errors", len(uploaded), len(errs)) + for _, e := range errs { + log.Printf(" ! %v", e) + } + if len(errs) > 0 { + os.Exit(1) + } +} diff --git a/backend/go.mod b/backend/go.mod index b377154..d4b0e71 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -25,6 +25,7 @@ require ( github.com/teslashibe/x-go v1.6.1 github.com/teslashibe/x-viral-go v0.2.0 github.com/valyala/fasthttp v1.70.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -60,5 +61,4 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/internal/agent/provision.go b/backend/internal/agent/provision.go index 7c39a87..ec159e3 100644 --- a/backend/internal/agent/provision.go +++ b/backend/internal/agent/provision.go @@ -30,6 +30,12 @@ type UserAgent struct { // (e.g. /mcp/v1/u/) and validated by the MCP transport. type MCPEndpointFn func(ctx context.Context, userID string) (mcpURL string, err error) +// SkillIDsFn returns the Anthropic skill IDs to attach when creating +// a per-user agent. Wired in main.go from the skills service so a +// fresh agent picks up whatever skills are currently uploaded. May +// return an empty slice when no skills are installed. +type SkillIDsFn func(ctx context.Context) ([]string, error) + // Provisioner lazily creates per-user Anthropic Agent + Environment // resources, caches them in the users table, and returns the cached pair on // subsequent calls. @@ -45,6 +51,7 @@ type Provisioner struct { endpoint MCPEndpointFn model anthropic.BetaManagedAgentsModel system string + skillIDs SkillIDsFn mu sync.Mutex inflight map[string]*sync.Mutex @@ -57,6 +64,10 @@ type ProvisionerOptions struct { Model anthropic.BetaManagedAgentsModel // SystemPrompt overrides the agent's system prompt. SystemPrompt string + // SkillIDs returns the Anthropic skill IDs to attach to new + // agents. When non-nil, agents are also given the code-execution + // tool so they can read the skills' SKILL.md files at runtime. + SkillIDs SkillIDsFn } // NewProvisioner constructs a Provisioner. endpoint is required. @@ -79,6 +90,7 @@ func NewProvisioner(cfg config.Config, client anthropic.Client, pool *pgxpool.Po endpoint: endpoint, model: model, system: system, + skillIDs: opts.SkillIDs, inflight: map[string]*sync.Mutex{}, }, nil } @@ -229,7 +241,29 @@ func (p *Provisioner) createAgent(ctx context.Context, userID string) (string, e } const mcpServerName = "engagement" - a, err := p.client.Beta.Agents.New(ctx, anthropic.BetaAgentNewParams{ + tools := []anthropic.BetaAgentNewParamsToolUnion{ + {OfMCPToolset: &anthropic.BetaManagedAgentsMCPToolsetParams{ + MCPServerName: mcpServerName, + Type: anthropic.BetaManagedAgentsMCPToolsetParamsTypeMCPToolset, + }}, + } + + // Skills require code-execution because Claude reads SKILL.md + // (and any bundled scripts) via bash inside the sandbox. We + // attach the agent toolset (which includes bash + code exec) + // alongside the existing MCP toolset only when at least one + // skill is configured — keeps the default permission surface + // unchanged for forks that don't use skills. + skills, _ := p.resolveSkills(ctx) + if len(skills) > 0 { + tools = append(tools, anthropic.BetaAgentNewParamsToolUnion{ + OfAgentToolset20260401: &anthropic.BetaManagedAgentsAgentToolset20260401Params{ + Type: anthropic.BetaManagedAgentsAgentToolset20260401ParamsTypeAgentToolset20260401, + }, + }) + } + + params := anthropic.BetaAgentNewParams{ Name: fmt.Sprintf("agent-setup-user-%s", userID), Description: anthropic.String("Per-user engagement agent with platform MCP tools"), System: anthropic.String(p.system), @@ -241,15 +275,32 @@ func (p *Provisioner) createAgent(ctx context.Context, userID string) (string, e Type: anthropic.BetaManagedAgentsURLMCPServerParamsTypeURL, URL: mcpURL, }}, - Tools: []anthropic.BetaAgentNewParamsToolUnion{ - {OfMCPToolset: &anthropic.BetaManagedAgentsMCPToolsetParams{ - MCPServerName: mcpServerName, - Type: anthropic.BetaManagedAgentsMCPToolsetParamsTypeMCPToolset, - }}, - }, - }) + Tools: tools, + Skills: skills, + } + + a, err := p.client.Beta.Agents.New(ctx, params) if err != nil { return "", err } return a.ID, nil } + +// resolveSkills converts the SkillIDsFn output into the SDK's union +// type. Errors are swallowed (logged elsewhere) so a transient DB +// hiccup doesn't block agent provisioning — the user can re-trigger +// once skills are reachable. +func (p *Provisioner) resolveSkills(ctx context.Context) ([]anthropic.BetaManagedAgentsSkillParamsUnion, error) { + if p.skillIDs == nil { + return nil, nil + } + ids, err := p.skillIDs(ctx) + if err != nil { + return nil, err + } + out := make([]anthropic.BetaManagedAgentsSkillParamsUnion, 0, len(ids)) + for _, id := range ids { + out = append(out, anthropic.BetaManagedAgentsSkillParamsOfCustom(id)) + } + return out, nil +} diff --git a/backend/internal/db/migrations/00005_skills.sql b/backend/internal/db/migrations/00005_skills.sql new file mode 100644 index 0000000..66aa4d5 --- /dev/null +++ b/backend/internal/db/migrations/00005_skills.sql @@ -0,0 +1,30 @@ +-- +goose Up +-- +goose StatementBegin + +-- skills mirrors what we've uploaded to Anthropic so the UI can list / +-- delete without round-tripping the API on every request, and so the +-- agent provisioner can attach the IDs to new agents without an extra +-- API call. UNIQUE(name) means re-syncing a folder updates the row in +-- place rather than creating a duplicate; the Anthropic ID is rotated +-- to point at the new version. +CREATE TABLE IF NOT EXISTS skills ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + anthropic_skill_id text NOT NULL, + name text NOT NULL UNIQUE, + description text NOT NULL, + source text NOT NULL DEFAULT 'custom', + version text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS skills_anthropic_id_idx ON skills (anthropic_skill_id); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS skills; + +-- +goose StatementEnd diff --git a/backend/internal/skills/handler.go b/backend/internal/skills/handler.go new file mode 100644 index 0000000..d731d48 --- /dev/null +++ b/backend/internal/skills/handler.go @@ -0,0 +1,107 @@ +package skills + +import ( + "io" + "net/http" + "strings" + + "github.com/gofiber/fiber/v2" + + "github.com/teslashibe/agent-setup/backend/internal/apperrors" +) + +// Handler exposes /api/skills CRUD. Mounted by main.go behind the +// shared `api` group so every route already requires a JWT. +type Handler struct{ svc *Service } + +func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } + +func (h *Handler) Mount(r fiber.Router) { + g := r.Group("/skills") + g.Get("/", h.list) + g.Post("/", h.upload) + g.Delete("/:id", h.delete) + g.Post("/sync", h.sync) +} + +// list returns every persisted skill. Anthropic-source skills aren't +// in our DB unless someone called sync against an anthropic_skills +// source dir, so this is purely "what does this workspace know about". +func (h *Handler) list(c *fiber.Ctx) error { + out, err := h.svc.List(c.UserContext()) + if err != nil { + return err + } + return c.JSON(fiber.Map{"skills": out}) +} + +// upload accepts a multipart form with a single `file` field +// containing a .zip of the skill folder. Anything else is a 400. +func (h *Handler) upload(c *fiber.Ctx) error { + fh, err := c.FormFile("file") + if err != nil { + return apperrors.New(http.StatusBadRequest, "missing 'file' upload (zip of the skill folder)") + } + if !strings.HasSuffix(strings.ToLower(fh.Filename), ".zip") { + return apperrors.New(http.StatusBadRequest, "upload must be a .zip") + } + f, err := fh.Open() + if err != nil { + return err + } + defer f.Close() + data, err := io.ReadAll(f) + if err != nil { + return err + } + sk, err := h.svc.UploadZip(c.UserContext(), data) + if err != nil { + // Most failures here are "bad zip" (no SKILL.md, multi-root, + // path traversal) — surface the message directly so the UI + // can show it. Anthropic-side errors include "anthropic + // upload:" which we still want to expose, just classified. + if strings.Contains(err.Error(), "anthropic upload") { + return apperrors.New(http.StatusBadGateway, err.Error()) + } + return apperrors.New(http.StatusBadRequest, err.Error()) + } + return c.Status(http.StatusCreated).JSON(sk) +} + +// delete removes by our row id (UUID). The Anthropic call happens +// inside the service and is best-effort: a 404 from Anthropic still +// removes the local row so the UI clears. +func (h *Handler) delete(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return apperrors.New(http.StatusBadRequest, "id is required") + } + if err := h.svc.Delete(c.UserContext(), id); err != nil { + return err + } + return c.SendStatus(http.StatusNoContent) +} + +// sync triggers a server-side bulk upload from SKILLS_SOURCES dirs. +// We accept an optional JSON body with `sources: []string` so an +// admin UI could override the env without restarting the server; an +// empty body falls back to the env var. +func (h *Handler) sync(c *fiber.Ctx) error { + var req struct { + Sources []string `json:"sources"` + } + _ = c.BodyParser(&req) + uploaded, errs := h.svc.SyncDirs(c.UserContext(), req.Sources, func(format string, args ...any) { + // fiber loggers write to stdout; this hook lets us pass a + // no-op or a structured logger if we wire one in later. + }) + out := fiber.Map{"uploaded": uploaded} + if len(errs) > 0 { + strs := make([]string, len(errs)) + for i, e := range errs { + strs[i] = e.Error() + } + out["errors"] = strs + } + return c.JSON(out) +} diff --git a/backend/internal/skills/skills.go b/backend/internal/skills/skills.go new file mode 100644 index 0000000..e8e139e --- /dev/null +++ b/backend/internal/skills/skills.go @@ -0,0 +1,323 @@ +// Package skills wires Anthropic's Beta Skills API into the template: +// scan a directory containing one-or-more SKILL.md folders, upload each +// folder to Anthropic, persist its skill_id, and surface the resulting +// list to the rest of the app (provisioner attaches them to agents, +// REST handler exposes CRUD to the mobile UI). +// +// SKILL.md format (Anthropic spec): +// +// --- +// name: my-skill +// description: <≤1024 chars; what it does AND when to use it> +// --- +// # body... +// +// Folder layout: one SKILL.md at the root, plus optional bundled files +// (scripts/, references/, wheels/). Everything in the folder is uploaded +// as a single skill version. +package skills + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/packages/param" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "gopkg.in/yaml.v3" +) + +// Skill is the row we persist + return from /api/skills. +type Skill struct { + ID string `json:"id"` + AnthropicID string `json:"anthropic_skill_id"` + Name string `json:"name"` + Description string `json:"description"` + Source string `json:"source"` // "custom" | "anthropic" + Version string `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Service owns the Anthropic client + Postgres store + filesystem sync. +// Construct one in cmd/server/main.go and reuse across handlers. +type Service struct { + client anthropic.Client + pool *pgxpool.Pool +} + +func NewService(client anthropic.Client, pool *pgxpool.Pool) *Service { + return &Service{client: client, pool: pool} +} + +// List returns every skill we've persisted, newest first. +func (s *Service) List(ctx context.Context) ([]Skill, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, anthropic_skill_id, name, description, source, version, created_at, updated_at + FROM skills ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Skill{} + for rows.Next() { + var sk Skill + if err := rows.Scan(&sk.ID, &sk.AnthropicID, &sk.Name, &sk.Description, &sk.Source, &sk.Version, &sk.CreatedAt, &sk.UpdatedAt); err != nil { + return nil, err + } + out = append(out, sk) + } + return out, rows.Err() +} + +// AnthropicIDs returns just the Anthropic skill IDs (for attaching to +// agents in the provisioner). Capped at 20 because Anthropic's +// BetaAgentNewParams.Skills enforces a 20-skill maximum per agent. +func (s *Service) AnthropicIDs(ctx context.Context) ([]string, error) { + rows, err := s.pool.Query(ctx, ` + SELECT anthropic_skill_id FROM skills + ORDER BY created_at ASC LIMIT 20`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// Delete removes the skill from Anthropic AND our DB. We delete from +// Anthropic first so a partial failure leaves us with a valid local +// reference (better than an orphaned remote skill). +func (s *Service) Delete(ctx context.Context, id string) error { + var anthropicID string + if err := s.pool.QueryRow(ctx, `SELECT anthropic_skill_id FROM skills WHERE id = $1`, id).Scan(&anthropicID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("skill %s not found", id) + } + return err + } + if _, err := s.client.Beta.Skills.Delete(ctx, anthropicID, anthropic.BetaSkillDeleteParams{}); err != nil { + // 404 from Anthropic = skill already gone remotely; fall through + // and clean up our row so the UI stops showing a dead entry. + if !strings.Contains(err.Error(), "404") { + return fmt.Errorf("anthropic delete: %w", err) + } + } + _, err := s.pool.Exec(ctx, `DELETE FROM skills WHERE id = $1`, id) + return err +} + +// UploadDir uploads the contents of dir as one skill. dir must contain +// SKILL.md at its root; that file's YAML frontmatter is parsed for +// name/description so we can persist them locally. +// +// Re-uploading a directory whose SKILL.md `name` already exists in our +// DB updates the existing row (single Anthropic skill with new versions +// over time) instead of inserting a duplicate. +func (s *Service) UploadDir(ctx context.Context, dir string) (Skill, error) { + dir = filepath.Clean(dir) + meta, err := readSkillMD(dir) + if err != nil { + return Skill{}, err + } + + files, closers, err := openSkillFiles(dir) + if err != nil { + return Skill{}, err + } + defer func() { + for _, c := range closers { + _ = c.Close() + } + }() + + resp, err := s.client.Beta.Skills.New(ctx, anthropic.BetaSkillNewParams{ + DisplayTitle: param.NewOpt(meta.Name), + Files: files, + }) + if err != nil { + return Skill{}, fmt.Errorf("anthropic upload: %w", err) + } + + now := time.Now().UTC() + var sk Skill + err = s.pool.QueryRow(ctx, ` + INSERT INTO skills (anthropic_skill_id, name, description, source, version, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $6) + ON CONFLICT (name) DO UPDATE + SET anthropic_skill_id = EXCLUDED.anthropic_skill_id, + description = EXCLUDED.description, + version = EXCLUDED.version, + updated_at = EXCLUDED.updated_at + RETURNING id, anthropic_skill_id, name, description, source, version, created_at, updated_at + `, resp.ID, meta.Name, meta.Description, resp.Source, resp.LatestVersion, now). + Scan(&sk.ID, &sk.AnthropicID, &sk.Name, &sk.Description, &sk.Source, &sk.Version, &sk.CreatedAt, &sk.UpdatedAt) + if err != nil { + return Skill{}, fmt.Errorf("persist skill: %w", err) + } + return sk, nil +} + +// SyncDirs walks each path in roots, finds every immediate child folder +// containing a SKILL.md, and uploads it. Folders without SKILL.md are +// skipped with a logged warning. Returns the list of skills that +// successfully uploaded; any per-skill error is collected into errs so +// one bad skill doesn't abort the whole sync. +func (s *Service) SyncDirs(ctx context.Context, roots []string, log func(format string, args ...any)) (uploaded []Skill, errs []error) { + for _, root := range roots { + root = strings.TrimSpace(root) + if root == "" { + continue + } + entries, err := os.ReadDir(root) + if err != nil { + errs = append(errs, fmt.Errorf("read %s: %w", root, err)) + continue + } + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(root, e.Name()) + if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { + log("skip %s: no SKILL.md at root", dir) + continue + } + sk, err := s.UploadDir(ctx, dir) + if err != nil { + errs = append(errs, fmt.Errorf("upload %s: %w", dir, err)) + continue + } + log("uploaded %s → %s (%s)", sk.Name, sk.AnthropicID, sk.Version) + uploaded = append(uploaded, sk) + } + } + return uploaded, errs +} + +// skillMeta is just the YAML frontmatter we care about. +type skillMeta struct { + Name string `yaml:"name"` + Description string `yaml:"description"` +} + +// readSkillMD parses the YAML frontmatter from /SKILL.md. Returns +// an error if the file is missing, the frontmatter delimiters are +// absent, or required fields are empty. +func readSkillMD(dir string) (skillMeta, error) { + raw, err := os.ReadFile(filepath.Join(dir, "SKILL.md")) + if err != nil { + return skillMeta{}, fmt.Errorf("read SKILL.md: %w", err) + } + const delim = "---" + body := strings.TrimLeft(string(raw), " \t\r\n") + if !strings.HasPrefix(body, delim) { + return skillMeta{}, fmt.Errorf("SKILL.md missing YAML frontmatter") + } + rest := body[len(delim):] + end := strings.Index(rest, "\n"+delim) + if end < 0 { + return skillMeta{}, fmt.Errorf("SKILL.md frontmatter not closed") + } + var m skillMeta + if err := yaml.Unmarshal([]byte(rest[:end]), &m); err != nil { + return skillMeta{}, fmt.Errorf("parse frontmatter: %w", err) + } + if m.Name == "" || m.Description == "" { + return skillMeta{}, fmt.Errorf("SKILL.md frontmatter missing name or description") + } + return m, nil +} + +// namedReader gives the SDK's apiform encoder a `Filename()` so the +// multipart upload preserves the relative path within the skill folder +// (e.g. astrology-skill/scripts/chart.py) instead of falling back to +// "anonymous_file". +type namedReader struct { + io.Reader + name string +} + +func (n namedReader) Filename() string { return n.name } + +// openSkillFiles walks dir recursively and returns one io.Reader per +// regular file with its filename set to the path RELATIVE to dir's +// parent (so the skill's root folder is the first path segment, which +// matches what Anthropic expects). Caller must close every entry in +// the returned closers slice. +func openSkillFiles(dir string) ([]io.Reader, []io.Closer, error) { + root := filepath.Dir(dir) + var files []io.Reader + var closers []io.Closer + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + // Skip junk that bloats uploads with no value. + if info.Name() == ".git" || info.Name() == "node_modules" || info.Name() == "__pycache__" { + return filepath.SkipDir + } + return nil + } + // .DS_Store, .git*, .pyc — skip silently. + base := filepath.Base(path) + if strings.HasPrefix(base, ".") || strings.HasSuffix(base, ".pyc") { + return nil + } + f, err := os.Open(path) + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + _ = f.Close() + return err + } + files = append(files, namedReader{Reader: f, name: filepath.ToSlash(rel)}) + closers = append(closers, f) + return nil + }) + if err != nil { + for _, c := range closers { + _ = c.Close() + } + return nil, nil, err + } + if len(files) == 0 { + return nil, nil, fmt.Errorf("no files found under %s", dir) + } + return files, closers, nil +} + +// UploadZip extracts an in-memory zip into a temp dir (must contain a +// single top-level folder with SKILL.md at its root) and uploads it. +// Used by the REST handler so the mobile UI can drop a zip without us +// touching the Anthropic API directly from the request handler. +func (s *Service) UploadZip(ctx context.Context, data []byte) (Skill, error) { + tmp, err := os.MkdirTemp("", "skill-upload-*") + if err != nil { + return Skill{}, err + } + defer os.RemoveAll(tmp) + root, err := unzipSingleSkill(bytes.NewReader(data), int64(len(data)), tmp) + if err != nil { + return Skill{}, err + } + return s.UploadDir(ctx, root) +} diff --git a/backend/internal/skills/zip.go b/backend/internal/skills/zip.go new file mode 100644 index 0000000..88fa8d2 --- /dev/null +++ b/backend/internal/skills/zip.go @@ -0,0 +1,74 @@ +package skills + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// unzipSingleSkill extracts r into tmp and returns the path to the +// single top-level folder it contained. Rejects zips that don't have +// exactly one root folder, or whose root folder lacks SKILL.md. +// +// Path-traversal hardening: any entry whose cleaned name escapes tmp +// is rejected so a malicious upload can't write outside the temp dir. +func unzipSingleSkill(r io.ReaderAt, size int64, tmp string) (string, error) { + zr, err := zip.NewReader(r, size) + if err != nil { + return "", fmt.Errorf("read zip: %w", err) + } + rootName := "" + for _, f := range zr.File { + clean := filepath.ToSlash(filepath.Clean(f.Name)) + if strings.HasPrefix(clean, "../") || strings.HasPrefix(clean, "/") { + return "", fmt.Errorf("zip entry escapes root: %s", f.Name) + } + segs := strings.SplitN(clean, "/", 2) + if len(segs) == 0 || segs[0] == "" || segs[0] == "." { + continue + } + if rootName == "" { + rootName = segs[0] + } else if rootName != segs[0] { + return "", fmt.Errorf("zip must contain exactly one top-level folder (saw %q and %q)", rootName, segs[0]) + } + + dst := filepath.Join(tmp, clean) + if f.FileInfo().IsDir() { + if err := os.MkdirAll(dst, 0o755); err != nil { + return "", err + } + continue + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return "", err + } + out, err := os.Create(dst) + if err != nil { + return "", err + } + in, err := f.Open() + if err != nil { + _ = out.Close() + return "", err + } + if _, err := io.Copy(out, in); err != nil { + _ = in.Close() + _ = out.Close() + return "", err + } + _ = in.Close() + _ = out.Close() + } + if rootName == "" { + return "", fmt.Errorf("zip is empty") + } + root := filepath.Join(tmp, rootName) + if _, err := os.Stat(filepath.Join(root, "SKILL.md")); err != nil { + return "", fmt.Errorf("zip root %s does not contain SKILL.md", rootName) + } + return root, nil +} From 8f27a1b02748c337810e7192759f83604f1e9537 Mon Sep 17 00:00:00 2001 From: Brendan Playford <34052452+teslashibe@users.noreply.github.com> Date: Sat, 9 May 2026 10:54:28 -0700 Subject: [PATCH 2/9] feat(skills): mobile UI + docs closes #35 (mobile slice) - mobile/services/skills.ts: typed client for /api/skills CRUD; uses expo/fetch for the multipart upload so we can build FormData. - mobile/app/(app)/skills.tsx: list + upload (zip via DocumentPicker) + delete (with confirm) + sync button. Empty state CTAs upload. - settings.tsx: link to Skills card. - _layout.tsx: register hidden 'skills' route so deep links work. - docs/SKILLS.md: SKILL.md format, folder layout, sandbox constraints (no network, bundle wheels), make skills-sync workflow, REST surface. - mobile/package.json: expo-document-picker dependency. Co-authored-by: Cursor --- docs/SKILLS.md | 91 +++++++++++++++++ mobile/app/(app)/_layout.tsx | 1 + mobile/app/(app)/settings.tsx | 15 +++ mobile/app/(app)/skills.tsx | 181 ++++++++++++++++++++++++++++++++++ mobile/package-lock.json | 10 ++ mobile/package.json | 1 + mobile/services/skills.ts | 56 +++++++++++ 7 files changed, 355 insertions(+) create mode 100644 docs/SKILLS.md create mode 100644 mobile/app/(app)/skills.tsx create mode 100644 mobile/services/skills.ts diff --git a/docs/SKILLS.md b/docs/SKILLS.md new file mode 100644 index 0000000..5681af1 --- /dev/null +++ b/docs/SKILLS.md @@ -0,0 +1,91 @@ +# Skills + +Native [Anthropic Agent Skills](https://docs.anthropic.com/en/docs/agents-and-tools/agent-skills/overview) wired into the template. A "skill" is a folder with a `SKILL.md` at root + optional bundled scripts and reference files. Anthropic hosts the skill, loads its YAML frontmatter into the agent's system prompt at startup, and reads the body / bundled files on-demand inside the code-execution sandbox. + +## SKILL.md format + +```yaml +--- +name: my-skill +description: One-line summary of what the skill does AND when to use it (≤1024 chars). +--- + +# My Skill + +Body markdown — workflows, tool guidance, etc. Reference bundled files +relatively, e.g. `scripts/compute.py`. +``` + +Required: `name` (≤64 chars, lowercase + hyphens), `description` (≤1024 chars). Optional: any bundled files in the same folder. + +## Folder layout + +``` +my-skill/ +├── SKILL.md ← required at root +├── scripts/ ← optional; Claude runs via bash +│ └── compute.py +├── references/ ← optional; Claude reads on-demand +│ └── notes.md +└── wheels/ ← optional; Python wheels for offline sandbox + └── *.whl +``` + +The sandbox has **no network and no `pip install`**. Bundle wheels under `wheels/` and reference them from your scripts (e.g. `pip install --no-index --find-links scripts/wheels mypkg`). + +## Bulk-import via `make skills-sync` + +1. Set `SKILLS_SOURCES` in `backend/.env` to a comma-separated list of directories whose immediate children are skill folders: + + ```bash + SKILLS_SOURCES=~/.claude/skills,~/.cursor/skills + ``` + +2. Run: + + ```bash + make skills-sync + ``` + +The CLI scans each source dir, finds every immediate child containing a `SKILL.md`, uploads it to Anthropic, and persists the resulting skill ID in the `skills` table. Re-running is idempotent (UPSERT on `name`). + +## Upload one skill from the UI + +Settings → Skills → Upload. Pick a `.zip` of the skill folder. The backend validates `SKILL.md` exists at the zip's root and rejects path-traversal entries before calling Anthropic. + +## How agents pick up skills + +The runtime per-user provisioner (`backend/internal/agent/provision.go`) reads `skills.AnthropicIDs(ctx)` when creating each user's Anthropic Agent and: + +1. Attaches up to 20 skill IDs (Anthropic's per-agent maximum). +2. Adds the **agent toolset** (bash + code-execution) alongside the existing MCP toolset so Claude can `cat SKILL.md` and run bundled scripts. + +When no skills are uploaded, neither the agent toolset nor the `Skills` field is set — keeping the default permission surface unchanged for forks that don't use skills. + +## Constraints (worth knowing) + +- **Workspace-scoped**: skills are shared across everyone using the same `ANTHROPIC_API_KEY`. No per-team isolation. +- **API sandbox is offline**: bundle dependencies as wheels. +- **20-skill cap per agent**: more than 20 in the DB will be silently truncated to the oldest 20 attached to each new agent. (Sort order: `created_at ASC`.) +- **Cross-surface**: skills uploaded via this API are not visible in claude.ai or Claude Code. Each surface uploads independently. + +## REST surface + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/skills/` | List installed skills | +| `POST` | `/api/skills/` | Multipart upload of a `.zip` (form field `file`) | +| `DELETE` | `/api/skills/:id` | Remove from Anthropic + local DB | +| `POST` | `/api/skills/sync` | Server-side bulk upload from `SKILLS_SOURCES` (or `{sources: []}` body) | + +All routes require the standard JWT. + +## Provisioning at bootstrap + +If you want the shared `cmd/provision` agent to also have skills attached at creation time, set: + +```bash +SKILLS_AGENT_IDS=skill_abc,skill_def +``` + +Most setups leave this empty and let the runtime per-user provisioner attach whatever skills the workspace currently has. diff --git a/mobile/app/(app)/_layout.tsx b/mobile/app/(app)/_layout.tsx index 8bc89e0..123ff6b 100644 --- a/mobile/app/(app)/_layout.tsx +++ b/mobile/app/(app)/_layout.tsx @@ -69,6 +69,7 @@ function AppTabs() { + ); } diff --git a/mobile/app/(app)/settings.tsx b/mobile/app/(app)/settings.tsx index 2c2934a..1666ac6 100644 --- a/mobile/app/(app)/settings.tsx +++ b/mobile/app/(app)/settings.tsx @@ -114,6 +114,21 @@ export default function SettingsScreen() { + + + Skills + + + + Drop a SKILL.md folder (zip) to teach Claude domain-specific workflows. Skills load on demand inside + the code-execution sandbox. + + + + + {NOTIFICATIONS_CAPTURE_ENABLED && capture.isAvailable ? ( diff --git a/mobile/app/(app)/skills.tsx b/mobile/app/(app)/skills.tsx new file mode 100644 index 0000000..29f993d --- /dev/null +++ b/mobile/app/(app)/skills.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useState } from "react"; +import { ActivityIndicator, Alert, FlatList, Pressable, RefreshControl, View } from "react-native"; +import * as DocumentPicker from "expo-document-picker"; +import { Sparkles, Trash2, Upload } from "lucide-react-native"; + +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { Card, CardContent } from "@/components/ui/Card"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { Text } from "@/components/ui/Text"; +import { deleteSkill, listSkills, syncSkills, uploadSkillZip, type Skill } from "@/services/skills"; + +// SkillsScreen is the operator's surface to the workspace's installed +// Anthropic Skills. The list mirrors what we've persisted server-side +// (which in turn mirrors Anthropic). Upload, delete, and a one-click +// sync from SKILLS_SOURCES round it out. +export default function SkillsScreen() { + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [uploading, setUploading] = useState(false); + const [syncing, setSyncing] = useState(false); + + const load = useCallback(async () => { + try { + setSkills(await listSkills()); + } catch (error) { + Alert.alert("Error", error instanceof Error ? error.message : "Failed to load skills"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const handleUpload = useCallback(async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + type: ["application/zip", "application/x-zip-compressed"], + copyToCacheDirectory: true + }); + if (result.canceled || !result.assets?.[0]) return; + const asset = result.assets[0]; + setUploading(true); + await uploadSkillZip({ + uri: asset.uri, + name: asset.name ?? "skill.zip", + mimeType: asset.mimeType ?? "application/zip" + }); + await load(); + } catch (error) { + Alert.alert("Upload failed", error instanceof Error ? error.message : "Could not upload skill"); + } finally { + setUploading(false); + } + }, [load]); + + const handleSync = useCallback(async () => { + setSyncing(true); + try { + const res = await syncSkills(); + const errs = res.errors?.length ? `\n\n${res.errors.length} error(s):\n${res.errors.join("\n")}` : ""; + Alert.alert("Sync complete", `Uploaded ${res.uploaded.length} skill(s).${errs}`); + await load(); + } catch (error) { + Alert.alert("Sync failed", error instanceof Error ? error.message : "Could not sync"); + } finally { + setSyncing(false); + } + }, [load]); + + const handleDelete = useCallback( + (skill: Skill) => { + Alert.alert("Delete skill?", `Remove "${skill.name}" from this workspace?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + await deleteSkill(skill.id); + setSkills((prev) => prev.filter((s) => s.id !== skill.id)); + } catch (error) { + Alert.alert("Error", error instanceof Error ? error.message : "Could not delete"); + } + } + } + ]); + }, + [] + ); + + if (loading) { + return ( + + + + ); + } + + return ( + + + + Skills + + {skills.length} installed in this workspace + + + + + + + + + s.id} + contentContainerStyle={{ padding: 20, paddingTop: 0, paddingBottom: 140, gap: 12 }} + refreshControl={ + { + setRefreshing(true); + void load(); + }} + /> + } + ListEmptyComponent={ + } + title="No skills yet" + description="Upload a SKILL.md folder zip, or run `make skills-sync` to bulk-import from your local skill directories." + actionLabel="Upload skill" + onAction={handleUpload} + /> + } + renderItem={({ item }) => ( + + + + + + + {item.name} + + {item.source} + + + {item.description} + + {item.version ? ( + + v{item.version} + + ) : null} + + handleDelete(item)} + hitSlop={8} + className="h-8 w-8 items-center justify-center rounded-full active:bg-destructive/10" + accessibilityLabel="Delete skill" + > + + + + + + )} + /> + + ); +} diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 1fdb159..a516264 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -17,6 +17,7 @@ "expo": "~55.0.9", "expo-clipboard": "^55.0.13", "expo-constants": "~55.0.9", + "expo-document-picker": "^55.0.13", "expo-font": "~14.0.9", "expo-haptics": "~55.0.9", "expo-image-picker": "~17.0.0", @@ -4997,6 +4998,15 @@ "react-native": "*" } }, + "node_modules/expo-document-picker": { + "version": "55.0.13", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-55.0.13.tgz", + "integrity": "sha512-IhswJElhdzs3fKDEKW8KXYRoFkWGEsXRMYAZT46Yo56zqqy8yQXrczo33RSwD2hFzNQBdLT97SJL9N311UyS3g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "55.0.16", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.16.tgz", diff --git a/mobile/package.json b/mobile/package.json index 287b9e1..31e1dfd 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -23,6 +23,7 @@ "expo": "~55.0.9", "expo-clipboard": "^55.0.13", "expo-constants": "~55.0.9", + "expo-document-picker": "^55.0.13", "expo-font": "~14.0.9", "expo-haptics": "~55.0.9", "expo-image-picker": "~17.0.0", diff --git a/mobile/services/skills.ts b/mobile/services/skills.ts new file mode 100644 index 0000000..ba192c6 --- /dev/null +++ b/mobile/services/skills.ts @@ -0,0 +1,56 @@ +import { fetch as expoFetch } from "expo/fetch"; + +import { API_URL } from "@/config"; +import { getAccessToken, request } from "@/services/api"; + +export type Skill = { + id: string; + anthropic_skill_id: string; + name: string; + description: string; + source: "custom" | "anthropic"; + version: string; + created_at: string; + updated_at: string; +}; + +export async function listSkills(): Promise { + const res = await request<{ skills: Skill[] }>("/api/skills/"); + return res.skills ?? []; +} + +export async function deleteSkill(id: string): Promise { + await request(`/api/skills/${id}`, { method: "DELETE" }); +} + +// uploadSkillZip POSTs a multipart form with a single 'file' field. +// We use expo/fetch (not the JSON helper) so we can build a FormData +// body — the backend validates SKILL.md exists at the zip's root before +// calling Anthropic, so the worst case here is a fast 400. +export async function uploadSkillZip(file: { uri: string; name: string; mimeType?: string }): Promise { + const token = await getAccessToken(); + if (!token) throw new Error("Not authenticated"); + const form = new FormData(); + form.append("file", { + uri: file.uri, + name: file.name, + type: file.mimeType ?? "application/zip" + } as unknown as Blob); + const res = await expoFetch(`${API_URL.replace(/\/+$/, "")}/api/skills/`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: form as unknown as BodyInit + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `upload failed (${res.status})`); + } + return (await res.json()) as Skill; +} + +export async function syncSkills(sources?: string[]): Promise<{ uploaded: Skill[]; errors?: string[] }> { + return request<{ uploaded: Skill[]; errors?: string[] }>("/api/skills/sync", { + method: "POST", + body: JSON.stringify({ sources: sources ?? [] }) + }); +} From 587eaf0c8a6221a619a6c7671324b1a761519378 Mon Sep 17 00:00:00 2001 From: Brendan Playford <34052452+teslashibe@users.noreply.github.com> Date: Sun, 10 May 2026 08:14:59 -0700 Subject: [PATCH 3/9] feat(skills): astrology MCP plugin + slim skill bundle (skill-calls-home pattern) Demonstrates how to build a skill whose compute can't fit in Anthropic's sandbox (native deps, data files, side effects). Splits the skill in two: - backend/internal/astrology + internal/mcp/platforms/astrology.go: pure-Go sun-sign computation registered as the astrology_birth_summary MCP tool. Stub for moon/ascendant/houses with comment pointing at mshafiee/swephgo for full Swiss Ephemeris accuracy. - skills/astrology/{SKILL.md, reference.md}: tiny skill that tells Claude to gather birth data, call the MCP tool, read reference.md for interpretation tables, and compose a tight reading. docs/SKILLS.md gains an 'Authoring skills for this template' section that documents the pattern, with the astrology skill as the worked example. Also calls out the constraints surfaced during live testing: no nested archives (.whl/.zip/.tar/.tgz/.gz are silently skipped) and display_title uniqueness on re-upload (issue #35 follow-up to fix via Versions.New). Co-authored-by: Cursor --- backend/internal/astrology/astrology.go | 170 ++++++++++++++++++++ backend/internal/mcp/platforms/astrology.go | 72 +++++++++ backend/internal/mcp/platforms/platforms.go | 1 + docs/SKILLS.md | 44 ++++- skills/astrology/SKILL.md | 42 +++++ skills/astrology/reference.md | 61 +++++++ 6 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 backend/internal/astrology/astrology.go create mode 100644 backend/internal/mcp/platforms/astrology.go create mode 100644 skills/astrology/SKILL.md create mode 100644 skills/astrology/reference.md diff --git a/backend/internal/astrology/astrology.go b/backend/internal/astrology/astrology.go new file mode 100644 index 0000000..685abcf --- /dev/null +++ b/backend/internal/astrology/astrology.go @@ -0,0 +1,170 @@ +// Package astrology is a small natal-chart computation engine sized for +// the Skill demonstration: pure Go, no CGO, no ephemeris data files. +// +// Scope (deliberately narrow): +// +// - Sun sign: COMPUTED CORRECTLY from birth date. +// - Element + modality: derived from the sun sign. +// - Birth data echo: returned for the agent to reference in its +// reading. +// +// What this is NOT: +// +// - A full natal chart (no moon, ascendant, houses, aspects, transits). +// For those you need a real ephemeris (Swiss Ephemeris via +// mshafiee/swephgo, or a Meeus implementation like soniakeys/meeus). +// Swap [Compute] for a real engine when the fork needs it; the MCP +// tool surface (and the calling SKILL.md) does not need to change. +// +// This pattern — skill instructions in Anthropic, compute server-side +// via MCP — is documented in docs/SKILLS.md. +package astrology + +import ( + "fmt" + "strings" + "time" +) + +// Chart is the JSON the MCP tool returns to the agent. +type Chart struct { + Birth BirthData `json:"birth"` + Sun Placement `json:"sun"` + Notes []string `json:"notes"` +} + +type BirthData struct { + DateUTC string `json:"date_utc"` // ISO-8601 in UTC + Lat float64 `json:"lat,omitempty"` + Lon float64 `json:"lon,omitempty"` + Place string `json:"place,omitempty"` +} + +type Placement struct { + Sign string `json:"sign"` // e.g. "Aries" + Symbol string `json:"symbol"` // e.g. "♈" + Element string `json:"element"` // Fire/Earth/Air/Water + Modality string `json:"modality"` // Cardinal/Fixed/Mutable + Polarity string `json:"polarity"` // Masculine/Feminine + Ruler string `json:"ruler"` // e.g. "Mars" +} + +// Compute returns the chart for the given UTC birth instant + location. +// place is optional — used only to echo back to the agent. +func Compute(birthUTC time.Time, lat, lon float64, place string) Chart { + sun := sunSign(birthUTC) + notes := []string{ + "Sun sign computed from civil calendar date in UTC.", + "Moon, ascendant, houses, aspects, and transits are not computed by this stub. Wire in a Swiss Ephemeris implementation to enable them.", + } + return Chart{ + Birth: BirthData{ + DateUTC: birthUTC.UTC().Format(time.RFC3339), + Lat: lat, + Lon: lon, + Place: strings.TrimSpace(place), + }, + Sun: sun, + Notes: notes, + } +} + +// Tropical zodiac date ranges (UTC). Boundaries vary by ~1 day across +// years due to the Sun's motion; the table below uses canonical +// astrological dates which match within ±1 day for almost every +// 20th–21st century birth. For exact boundaries swap in a real +// ephemeris. +var signTable = []struct { + name, symbol, element, modality, polarity, ruler string + start, end monthDay +}{ + {"Capricorn", "♑", "Earth", "Cardinal", "Feminine", "Saturn", monthDay{12, 22}, monthDay{1, 19}}, + {"Aquarius", "♒", "Air", "Fixed", "Masculine", "Saturn", monthDay{1, 20}, monthDay{2, 18}}, + {"Pisces", "♓", "Water", "Mutable", "Feminine", "Jupiter", monthDay{2, 19}, monthDay{3, 20}}, + {"Aries", "♈", "Fire", "Cardinal", "Masculine", "Mars", monthDay{3, 21}, monthDay{4, 19}}, + {"Taurus", "♉", "Earth", "Fixed", "Feminine", "Venus", monthDay{4, 20}, monthDay{5, 20}}, + {"Gemini", "♊", "Air", "Mutable", "Masculine", "Mercury", monthDay{5, 21}, monthDay{6, 20}}, + {"Cancer", "♋", "Water", "Cardinal", "Feminine", "Moon", monthDay{6, 21}, monthDay{7, 22}}, + {"Leo", "♌", "Fire", "Fixed", "Masculine", "Sun", monthDay{7, 23}, monthDay{8, 22}}, + {"Virgo", "♍", "Earth", "Mutable", "Feminine", "Mercury", monthDay{8, 23}, monthDay{9, 22}}, + {"Libra", "♎", "Air", "Cardinal", "Masculine", "Venus", monthDay{9, 23}, monthDay{10, 22}}, + {"Scorpio", "♏", "Water", "Fixed", "Feminine", "Pluto", monthDay{10, 23}, monthDay{11, 21}}, + {"Sagittarius", "♐", "Fire", "Mutable", "Masculine", "Jupiter", monthDay{11, 22}, monthDay{12, 21}}, +} + +type monthDay struct{ m, d int } + +func (a monthDay) gte(b monthDay) bool { + if a.m != b.m { + return a.m > b.m + } + return a.d >= b.d +} +func (a monthDay) lte(b monthDay) bool { + if a.m != b.m { + return a.m < b.m + } + return a.d <= b.d +} + +func sunSign(t time.Time) Placement { + md := monthDay{int(t.UTC().Month()), t.UTC().Day()} + for _, s := range signTable { + // Capricorn wraps the year; handle as two ranges. + if s.start.m == 12 && s.end.m == 1 { + if md.gte(s.start) || md.lte(s.end) { + return placementOf(s) + } + continue + } + if md.gte(s.start) && md.lte(s.end) { + return placementOf(s) + } + } + // Should not happen; defensive default. + return Placement{Sign: "Unknown"} +} + +func placementOf(s struct { + name, symbol, element, modality, polarity, ruler string + start, end monthDay +}) Placement { + return Placement{ + Sign: s.name, + Symbol: s.symbol, + Element: s.element, + Modality: s.modality, + Polarity: s.polarity, + Ruler: s.ruler, + } +} + +// ParseBirthDateTime parses the agent's input shape: an ISO-8601 date +// (e.g. "1990-05-12") plus optional 24h time ("14:32") and IANA tz +// ("America/New_York"). Returns the moment in UTC. Tz defaults to UTC +// when blank; time defaults to noon (12:00) when blank to keep the sun +// sign correct on cusp dates without requiring the user to remember +// their birth time. +func ParseBirthDateTime(date, hhmm, tz string) (time.Time, error) { + date = strings.TrimSpace(date) + if date == "" { + return time.Time{}, fmt.Errorf("date is required (YYYY-MM-DD)") + } + hhmm = strings.TrimSpace(hhmm) + if hhmm == "" { + hhmm = "12:00" + } + tz = strings.TrimSpace(tz) + if tz == "" { + tz = "UTC" + } + loc, err := time.LoadLocation(tz) + if err != nil { + return time.Time{}, fmt.Errorf("unknown timezone %q: %w", tz, err) + } + t, err := time.ParseInLocation("2006-01-02 15:04", date+" "+hhmm, loc) + if err != nil { + return time.Time{}, fmt.Errorf("invalid date/time: %w", err) + } + return t.UTC(), nil +} diff --git a/backend/internal/mcp/platforms/astrology.go b/backend/internal/mcp/platforms/astrology.go new file mode 100644 index 0000000..3dc039c --- /dev/null +++ b/backend/internal/mcp/platforms/astrology.go @@ -0,0 +1,72 @@ +package platforms + +import ( + "context" + "encoding/json" + + "github.com/teslashibe/mcptool" + + "github.com/teslashibe/agent-setup/backend/internal/astrology" + "github.com/teslashibe/agent-setup/backend/internal/credentials" + "github.com/teslashibe/agent-setup/backend/internal/mcp" +) + +// Astrology binds the in-process astrology engine to MCP. Demonstrates +// the "skill calls home" pattern documented in docs/SKILLS.md: the +// uploaded SKILL.md (~/skills/astrology) tells Claude to invoke +// astrology_birth_summary, the heavy compute (which would need a real +// ephemeris with bundled binary data — disallowed in Anthropic's +// sandbox) runs here in our backend instead. +// +// Credentialless: nothing to authenticate. Validator returns nil so the +// tool is always available without any per-user setup. +func Astrology() Plugin { + return Plugin{ + Binding: mcp.PlatformBinding{ + Provider: astrologyProvider{}, + NewClient: func(_ context.Context, _ json.RawMessage) (any, error) { return astrologyClient{}, nil }, + }, + Validator: nullValidator{platform: "astrology"}, + } +} + +type astrologyProvider struct{} +type astrologyClient struct{} + +func (astrologyProvider) Platform() string { return "astrology" } + +func (astrologyProvider) Tools() []mcptool.Tool { + return []mcptool.Tool{birthSummaryTool} +} + +// birthSummaryInput is the shape the agent passes. Date is the only +// required field; time + tz default sensibly so a user who only knows +// their birthday still gets an accurate sun sign. +type birthSummaryInput struct { + Date string `json:"date" jsonschema:"required" jsonschema_description:"Birth date in ISO-8601 (YYYY-MM-DD)."` + Time string `json:"time,omitempty" jsonschema_description:"24h local time (HH:MM). Defaults to 12:00 when omitted."` + Timezone string `json:"timezone,omitempty" jsonschema_description:"IANA timezone (e.g. America/New_York). Defaults to UTC when omitted."` + Lat float64 `json:"lat,omitempty" jsonschema_description:"Birth location latitude in decimal degrees (positive = north)."` + Lon float64 `json:"lon,omitempty" jsonschema_description:"Birth location longitude in decimal degrees (positive = east)."` + Place string `json:"place,omitempty" jsonschema_description:"Free-text place name (echoed back; does not affect computation)."` +} + +var birthSummaryTool = mcptool.Define[astrologyClient, birthSummaryInput]( + "astrology_birth_summary", + "Compute a natal birth summary (sun sign + element + modality + ruler) from a birth date, optional time, and optional location. Returns structured JSON the agent can pair with reference.md interpretation tables. Use this whenever the user asks for a chart, reading, or sun sign.", + "", + func(_ context.Context, _ astrologyClient, in birthSummaryInput) (any, error) { + t, err := astrology.ParseBirthDateTime(in.Date, in.Time, in.Timezone) + if err != nil { + return nil, &mcptool.Error{Code: "invalid_input", Message: err.Error()} + } + return astrology.Compute(t, in.Lat, in.Lon, in.Place), nil + }, +) + +// Re-declare what we need from the existing helpers so this file +// stays self-contained. nullValidator is defined in platforms.go and +// already accepts every platform name; we don't redeclare it here. +// +// Compile-time assertion that Plugin's wiring is complete. +var _ = credentials.Validator(nullValidator{}) diff --git a/backend/internal/mcp/platforms/platforms.go b/backend/internal/mcp/platforms/platforms.go index aff11d1..92b89b9 100644 --- a/backend/internal/mcp/platforms/platforms.go +++ b/backend/internal/mcp/platforms/platforms.go @@ -80,6 +80,7 @@ func All() []Plugin { Nextdoor(), ElevenLabs(), Codegen(), + Astrology(), } } diff --git a/docs/SKILLS.md b/docs/SKILLS.md index 5681af1..e0251a4 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -65,10 +65,52 @@ When no skills are uploaded, neither the agent toolset nor the `Skills` field is ## Constraints (worth knowing) - **Workspace-scoped**: skills are shared across everyone using the same `ANTHROPIC_API_KEY`. No per-team isolation. -- **API sandbox is offline**: bundle dependencies as wheels. +- **API sandbox is offline**: no network, no `pip install`. Pre-installed Python packages only. +- **No nested archives**: Anthropic rejects skills containing `.whl`, `.zip`, `.tar`, `.tgz`, or `.gz` files. The uploader skips them silently. +- **`display_title` must be unique**: re-uploading a folder under a name that already exists in your Anthropic workspace returns a 400. Until we wire `Versions.New` (issue #35 follow-up), `make skills-sync` is a one-shot per workspace; `make db-reset` + delete the Anthropic skills clears the slate. - **20-skill cap per agent**: more than 20 in the DB will be silently truncated to the oldest 20 attached to each new agent. (Sort order: `created_at ASC`.) - **Cross-surface**: skills uploaded via this API are not visible in claude.ai or Claude Code. Each surface uploads independently. +## Authoring skills for this template + +The Anthropic API sandbox is too constrained for skills with native deps (Swiss Ephemeris, Pandoc, image processing libraries, etc.). The pattern that works in this template is **"skill calls home"**: split the skill into instructions (Anthropic-side) and compute (your-MCP-server-side). + +### The pattern + +1. **Compute lives in your backend** as a new MCP plugin under `backend/internal/mcp/platforms/`. The plugin registers tools the agent can call. +2. **The skill is small**: just `SKILL.md` + maybe `reference.md`. No `scripts/`, no `wheels/`, no native code. +3. **`SKILL.md` tells Claude when to call which tool**, and how to format the result. +4. **The MCP toolset is already attached** to every per-user agent — your tool is reachable via the same mechanism that exposes `linkedin_search_people`, etc. + +### Worked example: `skills/astrology/` + +The included `astrology` skill demonstrates the pattern end-to-end: + +- **`backend/internal/astrology/astrology.go`** — pure-Go natal computation (sun sign / element / modality / ruler). No CGO, no data files. Documented as a stub for moon/ascendant/houses; swap in [`mshafiee/swephgo`](https://github.com/mshafiee/swephgo) for full Swiss Ephemeris accuracy when you need it. +- **`backend/internal/mcp/platforms/astrology.go`** — registers `astrology_birth_summary(date, time, timezone, lat, lon, place) → Chart` as an MCP tool. ~70 lines. +- **`skills/astrology/SKILL.md`** — instructions: gather inputs → call the tool → read `reference.md` → compose the reading. +- **`skills/astrology/reference.md`** — interpretation tables Claude pulls from when writing the reading. + +To upload it: `SKILLS_SOURCES=skills make skills-sync`. + +### When to use what + +| You need... | Where it goes | +|---|---| +| One-off interpretive guidance ("how to draft a follow-up email") | All in `SKILL.md` body. No backend code. | +| Reference data Claude looks at when relevant | `reference.md` in the skill folder. | +| Pure-Python compute using only numpy/pandas/matplotlib | A bundled script in `scripts/`. The sandbox can run it. | +| Native deps (C extensions), large data files, or your own SaaS | New MCP plugin in `backend/internal/mcp/platforms/`; SKILL.md tells Claude which tool to call. | +| Side effects on user data (write to a doc, send an email) | New MCP plugin (NOT a script — scripts can't reach the outside world). | + +### Naming + discoverability + +`SKILL.md`'s `description` is the only thing Anthropic loads into the system prompt at startup, so it has to do double duty: explain what the skill does AND signal when to use it. Pattern: + +> "Generate a natal birth-chart reading from the user's birth date... Use when the user asks for a chart, reading, sun sign, or asks to interpret an astrological placement." + +Lead with the capability, end with trigger phrases. Without the trigger half, Claude won't know to invoke the skill on the user's actual phrasing. + ## REST surface | Method | Path | Description | diff --git a/skills/astrology/SKILL.md b/skills/astrology/SKILL.md new file mode 100644 index 0000000..b133173 --- /dev/null +++ b/skills/astrology/SKILL.md @@ -0,0 +1,42 @@ +--- +name: astrology +description: Generate a natal birth-chart reading from the user's birth date (and optionally time, timezone, and location). Use when the user asks for a chart, reading, sun sign, or asks to interpret an astrological placement. The skill calls the astrology_birth_summary MCP tool for the computed values, then composes the reading from the included reference table. +--- + +# Astrology + +You have access to a backend tool that does the math, plus a reference table that does the interpretation. Your job is to gather the inputs, call the tool, and write a tight, specific reading the user actually wants to read. + +## How to use + +1. **Gather inputs.** Ask the user for: + - Birth **date** (required, YYYY-MM-DD). + - Birth **time** (optional, HH:MM 24h). If they don't know, default to noon. + - Birth **timezone** (IANA, e.g. `America/New_York`). If unknown, default to UTC and note the assumption. + - Birth **place** (optional free text — echoed back, not used in calculation). + If the user already gave you any of these in their message, don't re-ask. + +2. **Call `astrology_birth_summary`** with those inputs. The tool returns: + ```json + { + "birth": { "date_utc": "...", "lat": 0, "lon": 0, "place": "" }, + "sun": { "sign": "Leo", "symbol": "♌", "element": "Fire", "modality": "Fixed", "polarity": "Masculine", "ruler": "Sun" }, + "notes": ["..."] + } + ``` + +3. **Read `reference.md`** in this skill folder for the interpretation table. Pull the entries for the user's element + modality + sign + ruler. Keep what's relevant; drop what's noise. + +4. **Compose the reading.** Format: + - One-paragraph **opening** that names the sun sign and what it suggests about the user's core temperament. + - **Element & modality** — what the combination tends to look like in practice. + - **Ruler** — the planet that signals their drive / appetite. + - One **practical observation** the user can take away (a tendency to leverage, or a trap to watch). + Keep the whole reading under ~200 words unless the user asks for depth. + +5. **Acknowledge what's missing.** If the tool's `notes` array mentions that moon / ascendant / houses aren't computed, say so briefly in one line — don't pretend you have them. + +## Constraints + +- Don't invent placements. If the user asks for moon sign or rising sign, tell them this skill computes only the sun and direct them to a service that supports the full chart, OR ask them to provide the moon sign and use the reference table to interpret it. +- Don't be mystical for the sake of it. Readings should sound like a thoughtful friend who happens to know the symbol set, not a horoscope app. diff --git a/skills/astrology/reference.md b/skills/astrology/reference.md new file mode 100644 index 0000000..31cab72 --- /dev/null +++ b/skills/astrology/reference.md @@ -0,0 +1,61 @@ +# Astrology reference + +Compact interpretation tables. Pull only the rows you need for a given reading. + +## Sun signs + +| Sign | Symbol | Dates (approx.) | Core themes | +|---|---|---|---| +| Aries | ♈ | Mar 21 – Apr 19 | Initiative, impatience, willingness to start before being ready. | +| Taurus | ♉ | Apr 20 – May 20 | Steadiness, sensual pleasure, stubbornness as a feature. | +| Gemini | ♊ | May 21 – Jun 20 | Curiosity, breadth over depth, talking-as-thinking. | +| Cancer | ♋ | Jun 21 – Jul 22 | Care, memory, protectiveness, mood as information. | +| Leo | ♌ | Jul 23 – Aug 22 | Warmth, performance, the desire to be seen and to give generously. | +| Virgo | ♍ | Aug 23 – Sep 22 | Craft, precision, an eye for what's missing or off. | +| Libra | ♎ | Sep 23 – Oct 22 | Pairing, aesthetics, the diplomat's instinct for balance. | +| Scorpio | ♏ | Oct 23 – Nov 21 | Intensity, depth, the appetite for what's hidden. | +| Sagittarius | ♐ | Nov 22 – Dec 21 | Range, optimism, the urge to keep horizons moving. | +| Capricorn | ♑ | Dec 22 – Jan 19 | Ambition, time-discipline, comfort with structure. | +| Aquarius | ♒ | Jan 20 – Feb 18 | Systems-thinking, group identity, the contrarian's clarity. | +| Pisces | ♓ | Feb 19 – Mar 20 | Imagination, empathy, dissolution of self/other boundaries. | + +## Elements + +| Element | Signs | Tendency | +|---|---|---| +| Fire | Aries, Leo, Sagittarius | Energy outward; momentum, enthusiasm, occasional overshoot. | +| Earth | Taurus, Virgo, Capricorn | Grounded, practical, slow to start but durable once moving. | +| Air | Gemini, Libra, Aquarius | Mental, social, abstracts well; can over-rely on talking. | +| Water | Cancer, Scorpio, Pisces | Feeling-led, attuned to undercurrents; needs solitude to reset. | + +## Modalities + +| Modality | Signs | Tendency | +|---|---|---| +| Cardinal | Aries, Cancer, Libra, Capricorn | Initiates; sets the agenda. | +| Fixed | Taurus, Leo, Scorpio, Aquarius | Sustains; doesn't move easily once set. | +| Mutable | Gemini, Virgo, Sagittarius, Pisces | Adapts; absorbs and re-emits. | + +## Rulers (traditional + modern) + +| Planet | Rules | Drive it signals | +|---|---|---| +| Sun | Leo | Self-expression, vitality. | +| Moon | Cancer | Emotional rhythm, what one needs to feel safe. | +| Mercury | Gemini, Virgo | Communication, processing style. | +| Venus | Taurus, Libra | Pleasure, taste, what one is drawn to. | +| Mars | Aries (and trad. Scorpio) | Drive, anger, sexual appetite. | +| Jupiter | Sagittarius (and trad. Pisces) | Expansion, optimism, philosophy. | +| Saturn | Capricorn (and trad. Aquarius) | Structure, discipline, limits. | +| Uranus | Aquarius (modern) | Disruption, sudden change, originality. | +| Neptune | Pisces (modern) | Dreams, dissolution, idealism. | +| Pluto | Scorpio (modern) | Transformation, power, the underworld. | + +## Compatibility quick notes (sun-to-sun, broad strokes) + +- **Same element**: easy rapport, can lack friction. +- **Adjacent elements (Fire+Air, Earth+Water)**: complementary, often productive. +- **Opposing elements (Fire+Water, Earth+Air)**: more friction, more growth. +- **Same modality, different element**: similar tempo, different fuel — recognise each other. +- **Square (90° apart, e.g. Aries↔Cancer)**: classic challenge pairing. +- **Opposition (180°, e.g. Aries↔Libra)**: mirror — what each lacks the other has. From 4de4fc589cfa693c6ab1bc2388b89239d487b65e Mon Sep 17 00:00:00 2001 From: Brendan Playford <34052452+teslashibe@users.noreply.github.com> Date: Sun, 10 May 2026 08:15:16 -0700 Subject: [PATCH 4/9] fix(skills): handle symlinked source dirs + skip nested archive files Surfaced during live testing of the astrology skill upload: - ~/.claude/skills/foo is canonically a symlink at the real repo. The sync's e.IsDir() check returned false on symlink entries; switch to os.Stat (follows symlinks) and resolve via filepath.EvalSymlinks before walking, since filepath.Walk does not follow symlinks itself. Preserve the visible folder name in the uploaded paths so the skill still appears as 'foo' to Anthropic even when the symlink target is named differently. - Anthropic's API rejects 'Skill cannot contain nested zip files'. Skip .whl, .zip, .tar, .tgz, .gz extensions in openSkillFiles so the rest of the skill still uploads. The astrology skill ships pyswisseph wheels under scripts/wheels/ that triggered this. Co-authored-by: Cursor --- backend/internal/skills/skills.go | 39 +++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/backend/internal/skills/skills.go b/backend/internal/skills/skills.go index e8e139e..45b117c 100644 --- a/backend/internal/skills/skills.go +++ b/backend/internal/skills/skills.go @@ -190,10 +190,14 @@ func (s *Service) SyncDirs(ctx context.Context, roots []string, log func(format continue } for _, e := range entries { - if !e.IsDir() { + dir := filepath.Join(root, e.Name()) + // os.Stat (not Lstat) follows symlinks so the canonical + // install layout — `~/.claude/skills/foo` symlinked at the + // real repo — is treated like an inline folder. + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { continue } - dir := filepath.Join(root, e.Name()) if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { log("skip %s: no SKILL.md at root", dir) continue @@ -260,11 +264,24 @@ func (n namedReader) Filename() string { return n.name } // parent (so the skill's root folder is the first path segment, which // matches what Anthropic expects). Caller must close every entry in // the returned closers slice. +// +// We resolve symlinks first because `filepath.Walk` does not traverse +// them — the canonical install layout symlinks ~/.claude/skills/foo +// at the real repo, and without EvalSymlinks the walker sees a single +// "file" entry that errors on Open as "is a directory". func openSkillFiles(dir string) ([]io.Reader, []io.Closer, error) { - root := filepath.Dir(dir) + resolved, err := filepath.EvalSymlinks(dir) + if err != nil { + return nil, nil, fmt.Errorf("resolve %s: %w", dir, err) + } + // Keep the visible folder name (e.g. "astrology-skill") in the + // uploaded paths even when the symlink points at a differently + // named directory. + displayBase := filepath.Base(dir) + root := filepath.Dir(resolved) var files []io.Reader var closers []io.Closer - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + err = filepath.Walk(resolved, func(path string, info os.FileInfo, err error) error { if err != nil { return err } @@ -280,6 +297,14 @@ func openSkillFiles(dir string) ([]io.Reader, []io.Closer, error) { if strings.HasPrefix(base, ".") || strings.HasSuffix(base, ".pyc") { return nil } + // Anthropic's API rejects skills containing nested archive + // files with "Skill cannot contain nested zip files". .whl + // (Python wheels) and .zip files trigger this — skip them + // so the rest of the skill still uploads. + ext := strings.ToLower(filepath.Ext(base)) + if ext == ".whl" || ext == ".zip" || ext == ".tar" || ext == ".tgz" || ext == ".gz" { + return nil + } f, err := os.Open(path) if err != nil { return err @@ -289,6 +314,12 @@ func openSkillFiles(dir string) ([]io.Reader, []io.Closer, error) { _ = f.Close() return err } + // Rebase the resolved path's first segment back onto the + // display name so Anthropic sees `/SKILL.md` + // even when the symlink target was named differently. + if resolvedBase := filepath.Base(resolved); resolvedBase != displayBase { + rel = strings.Replace(rel, resolvedBase, displayBase, 1) + } files = append(files, namedReader{Reader: f, name: filepath.ToSlash(rel)}) closers = append(closers, f) return nil From 248350ff68706c55f3c68c8a77162dfb249102f9 Mon Sep 17 00:00:00 2001 From: Brendan Playford <34052452+teslashibe@users.noreply.github.com> Date: Sun, 10 May 2026 08:28:35 -0700 Subject: [PATCH 5/9] fix(skills): close all audit findings + extra constraints surfaced live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes the H1/H2/H3 + M1/M2/M3 findings from the forensic audit on PR #36 and three more constraints surfaced during live testing. H1 — display_title uniqueness on re-upload (audit + live) Anthropic rejects Skills.New when a skill with that display_title already exists. Service.UploadDir now looks up the prior anthropic skill ID for that name and uses Beta.Skills.Versions.New on collision; only first uploads call Beta.Skills.New. H2 — newly-uploaded skills don't reach cached per-user agents refreshAgentsAsync fires after every successful upload/delete: it enumerates every users.anthropic_agent_id and pushes the current skill list via Beta.Agents.Update. Fired-and-forgotten so upload latency stays low; failures are logged. H3 — Fiber's 4 MiB default body limit rejects wheel-bundled skills Bumped fiber.Config.BodyLimit to 64 MiB (matches Anthropic's per-skill cap). Verified live with a 6 MiB upload that previously 413'd. NEW: Cannot delete skill while versions exist deleteAllVersions enumerates every version via the Versions service and deletes each before calling Skills.Delete. 404s on individual versions are swallowed. NEW: SDK helper BetaManagedAgentsSkillParamsOfCustom omits required Type Returns 400 'skills[0].type: Field required'. Wrapped in skills.SkillParams which sets Type to BetaManagedAgentsCustomSkillParamsTypeCustom. Provisioner refactored to use it. NEW: Anthropic's cloud cannot reach loopback MCP URLs createAgent now fails fast with an actionable hint pointing at ngrok/cloudflared instead of waiting for Anthropic to return a generic 400. M1 — silent truncation at 20-skill cap AnthropicIDs now logs the names of skills it dropped. M2 — brittle 404 detection Added is404 helper using errors.As(*anthropic.Error{}); replaces the strings.Contains heuristic in Delete + deleteAllVersions. M3 — bash-source brittleness in Makefile Dropped the 'source backend/.env' incantation; the CLI uses godotenv.Load() so the Makefile target is one line now. Bonus: Astrology binding sets NoCredentials: true so the demonstration tool doesn't trigger the credentials guard for credentialless plugins. skills-sync waits 2s before exiting so the async refresh goroutine can drain (was logging 'closed pool' in CLI output). Co-authored-by: Cursor --- Makefile | 3 +- backend/cmd/server/main.go | 8 +- backend/cmd/skills-sync/main.go | 8 + backend/internal/agent/provision.go | 20 +- backend/internal/mcp/platforms/astrology.go | 7 +- backend/internal/skills/skills.go | 196 +++++++++++++++++--- 6 files changed, 201 insertions(+), 41 deletions(-) diff --git a/Makefile b/Makefile index 78f3113..db6f563 100644 --- a/Makefile +++ b/Makefile @@ -98,8 +98,7 @@ managed-agents-provision: ## Create Anthropic Agent + Environment (run once, sto cd backend && go run ./cmd/provision skills-sync: ## Upload every SKILL.md folder under $$SKILLS_SOURCES to Anthropic - @set -a; source backend/.env 2>/dev/null || true; set +a; \ - cd backend && go run ./cmd/skills-sync + cd backend && go run ./cmd/skills-sync clean: ## Remove generated artifacts $(COMPOSE) down -v diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 56fdb30..9148c78 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -78,7 +78,13 @@ func main() { app := fiber.New(fiber.Config{ AppName: "Claude Agent Go", StreamRequestBody: true, - ErrorHandler: apperrors.FiberHandler, + // Fiber defaults to 4 MiB which is too small for skill bundles + // (canonical example: the astrology skill ships ~5 MiB of + // pyswisseph wheels). 64 MiB matches Anthropic's own per-skill + // size limit and keeps `expo/fetch` multipart uploads within + // memory bounds we're comfortable with. + BodyLimit: 64 * 1024 * 1024, + ErrorHandler: apperrors.FiberHandler, }) app.Use(recover.New(), logger.New(), cors.New(cors.Config{ AllowOrigins: cfg.CORSAllowedOrigins, diff --git a/backend/cmd/skills-sync/main.go b/backend/cmd/skills-sync/main.go index 33fd5f9..d7c9cde 100644 --- a/backend/cmd/skills-sync/main.go +++ b/backend/cmd/skills-sync/main.go @@ -13,6 +13,7 @@ import ( "log" "os" "strings" + "time" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" @@ -53,6 +54,13 @@ func main() { for _, e := range errs { log.Printf(" ! %v", e) } + // SyncDirs spawns refreshAgentsAsync goroutines per upload to push + // the new skill list onto every cached per-user agent. Give them a + // moment to drain before we close the pool — without this the CLI + // exits and the goroutines log "closed pool" trying to query users. + if len(uploaded) > 0 { + time.Sleep(2 * time.Second) + } if len(errs) > 0 { os.Exit(1) } diff --git a/backend/internal/agent/provision.go b/backend/internal/agent/provision.go index ec159e3..c3ba9ea 100644 --- a/backend/internal/agent/provision.go +++ b/backend/internal/agent/provision.go @@ -13,6 +13,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/teslashibe/agent-setup/backend/internal/config" + "github.com/teslashibe/agent-setup/backend/internal/skills" ) // UserAgent is the cached pair of Anthropic Agent + Environment IDs that @@ -236,9 +237,17 @@ func (p *Provisioner) createAgent(ctx context.Context, userID string) (string, e if err != nil { return "", fmt.Errorf("mcp endpoint: %w", err) } - if _, err := url.Parse(mcpURL); err != nil { + u, err := url.Parse(mcpURL) + if err != nil { return "", fmt.Errorf("invalid MCP url %q: %w", mcpURL, err) } + // Anthropic's cloud cannot reach loopback. Catch this on our side + // so the operator sees an actionable hint instead of a generic + // 400 from Anthropic ("MCP server URL host resolves to loopback"). + host := u.Hostname() + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return "", fmt.Errorf("MCP_PUBLIC_URL/APP_URL resolves to loopback (%s); for local dev, expose via ngrok or cloudflared and set MCP_PUBLIC_URL to the public URL", host) + } const mcpServerName = "engagement" tools := []anthropic.BetaAgentNewParamsToolUnion{ @@ -287,7 +296,8 @@ func (p *Provisioner) createAgent(ctx context.Context, userID string) (string, e } // resolveSkills converts the SkillIDsFn output into the SDK's union -// type. Errors are swallowed (logged elsewhere) so a transient DB +// type via skills.SkillParams (which sets the required Type field the +// stock SDK helper omits). Errors are swallowed so a transient DB // hiccup doesn't block agent provisioning — the user can re-trigger // once skills are reachable. func (p *Provisioner) resolveSkills(ctx context.Context) ([]anthropic.BetaManagedAgentsSkillParamsUnion, error) { @@ -298,9 +308,5 @@ func (p *Provisioner) resolveSkills(ctx context.Context) ([]anthropic.BetaManage if err != nil { return nil, err } - out := make([]anthropic.BetaManagedAgentsSkillParamsUnion, 0, len(ids)) - for _, id := range ids { - out = append(out, anthropic.BetaManagedAgentsSkillParamsOfCustom(id)) - } - return out, nil + return skills.SkillParams(ids), nil } diff --git a/backend/internal/mcp/platforms/astrology.go b/backend/internal/mcp/platforms/astrology.go index 3dc039c..60c76b6 100644 --- a/backend/internal/mcp/platforms/astrology.go +++ b/backend/internal/mcp/platforms/astrology.go @@ -23,8 +23,9 @@ import ( func Astrology() Plugin { return Plugin{ Binding: mcp.PlatformBinding{ - Provider: astrologyProvider{}, - NewClient: func(_ context.Context, _ json.RawMessage) (any, error) { return astrologyClient{}, nil }, + Provider: astrologyProvider{}, + NewClient: func(_ context.Context, _ json.RawMessage) (any, error) { return astrologyClient{}, nil }, + NoCredentials: true, // skip the credentials lookup entirely }, Validator: nullValidator{platform: "astrology"}, } @@ -53,7 +54,7 @@ type birthSummaryInput struct { var birthSummaryTool = mcptool.Define[astrologyClient, birthSummaryInput]( "astrology_birth_summary", - "Compute a natal birth summary (sun sign + element + modality + ruler) from a birth date, optional time, and optional location. Returns structured JSON the agent can pair with reference.md interpretation tables. Use this whenever the user asks for a chart, reading, or sun sign.", + "Compute sun sign, element, modality, and ruler from a birth date. Use for chart/reading/sun-sign requests.", "", func(_ context.Context, _ astrologyClient, in birthSummaryInput) (any, error) { t, err := astrology.ParseBirthDateTime(in.Date, in.Time, in.Timezone) diff --git a/backend/internal/skills/skills.go b/backend/internal/skills/skills.go index 45b117c..4549b7b 100644 --- a/backend/internal/skills/skills.go +++ b/backend/internal/skills/skills.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "io" + "log" "os" "path/filepath" "strings" @@ -35,6 +36,11 @@ import ( "gopkg.in/yaml.v3" ) +// maxAgentSkills is Anthropic's documented per-agent cap on attached +// skills. We surface a warning when AnthropicIDs returns this many so +// the operator knows the truncation happened. +const maxAgentSkills = 20 + // Skill is the row we persist + return from /api/skills. type Skill struct { ID string `json:"id"` @@ -78,31 +84,46 @@ func (s *Service) List(ctx context.Context) ([]Skill, error) { return out, rows.Err() } -// AnthropicIDs returns just the Anthropic skill IDs (for attaching to -// agents in the provisioner). Capped at 20 because Anthropic's -// BetaAgentNewParams.Skills enforces a 20-skill maximum per agent. +// AnthropicIDs returns the Anthropic skill IDs to attach to a new +// agent. Capped at maxAgentSkills (Anthropic's per-agent maximum). On +// truncation we log the names of the skills that didn't make it so +// the operator gets a visible signal instead of silent loss. func (s *Service) AnthropicIDs(ctx context.Context) ([]string, error) { rows, err := s.pool.Query(ctx, ` - SELECT anthropic_skill_id FROM skills - ORDER BY created_at ASC LIMIT 20`) + SELECT anthropic_skill_id, name FROM skills + ORDER BY created_at ASC`) if err != nil { return nil, err } defer rows.Close() out := []string{} + dropped := []string{} for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { + var id, name string + if err := rows.Scan(&id, &name); err != nil { return nil, err } - out = append(out, id) + if len(out) < maxAgentSkills { + out = append(out, id) + } else { + dropped = append(dropped, name) + } } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + if len(dropped) > 0 { + log.Printf("skills: %d skill(s) over the %d-skill per-agent cap not attached: %s", + len(dropped), maxAgentSkills, strings.Join(dropped, ", ")) + } + return out, nil } -// Delete removes the skill from Anthropic AND our DB. We delete from -// Anthropic first so a partial failure leaves us with a valid local -// reference (better than an orphaned remote skill). +// Delete removes the skill from Anthropic AND our DB. Anthropic +// rejects skill deletion while versions exist ("Cannot delete skill +// with existing versions"), so we list + delete every version first. +// 404s are swallowed so a re-run after a partial failure cleans up +// the local row. func (s *Service) Delete(ctx context.Context, id string) error { var anthropicID string if err := s.pool.QueryRow(ctx, `SELECT anthropic_skill_id FROM skills WHERE id = $1`, id).Scan(&anthropicID); err != nil { @@ -111,24 +132,73 @@ func (s *Service) Delete(ctx context.Context, id string) error { } return err } - if _, err := s.client.Beta.Skills.Delete(ctx, anthropicID, anthropic.BetaSkillDeleteParams{}); err != nil { - // 404 from Anthropic = skill already gone remotely; fall through - // and clean up our row so the UI stops showing a dead entry. - if !strings.Contains(err.Error(), "404") { - return fmt.Errorf("anthropic delete: %w", err) + + if err := s.deleteAllVersions(ctx, anthropicID); err != nil { + return fmt.Errorf("delete versions: %w", err) + } + if _, err := s.client.Beta.Skills.Delete(ctx, anthropicID, anthropic.BetaSkillDeleteParams{}); err != nil && !is404(err) { + return fmt.Errorf("anthropic delete: %w", err) + } + if _, err := s.pool.Exec(ctx, `DELETE FROM skills WHERE id = $1`, id); err != nil { + return err + } + s.refreshAgentsAsync() + return nil +} + +// deleteAllVersions enumerates and deletes every version of skillID +// (auto-pages through Anthropic's cursor-based listing). 404s on the +// list itself mean the skill is already gone — return nil so the +// caller can clean up the local row. +func (s *Service) deleteAllVersions(ctx context.Context, skillID string) error { + page := s.client.Beta.Skills.Versions.ListAutoPaging(ctx, skillID, anthropic.BetaSkillVersionListParams{}) + for page.Next() { + v := page.Current() + if _, err := s.client.Beta.Skills.Versions.Delete(ctx, v.Version, anthropic.BetaSkillVersionDeleteParams{SkillID: skillID}); err != nil && !is404(err) { + return fmt.Errorf("version %s: %w", v.Version, err) } } - _, err := s.pool.Exec(ctx, `DELETE FROM skills WHERE id = $1`, id) - return err + if err := page.Err(); err != nil && !is404(err) { + return err + } + return nil +} + +// is404 reports whether err is an Anthropic API error with status 404. +// Replaces the previous strings.Contains heuristic so SDK upgrades or +// localised error messages don't break the cleanup path. +func is404(err error) bool { + var apiErr *anthropic.Error + return errors.As(err, &apiErr) && apiErr.StatusCode == 404 +} + +// SkillParams converts skill IDs to the SDK union type the Agents API +// expects. The SDK's BetaManagedAgentsSkillParamsOfCustom helper omits +// the required Type field — passing its output directly returns a 400 +// "skills[0].type: Field required". This wrapper sets Type so the +// provisioner doesn't have to know about that quirk. +func SkillParams(ids []string) []anthropic.BetaManagedAgentsSkillParamsUnion { + out := make([]anthropic.BetaManagedAgentsSkillParamsUnion, 0, len(ids)) + for _, id := range ids { + out = append(out, anthropic.BetaManagedAgentsSkillParamsUnion{ + OfCustom: &anthropic.BetaManagedAgentsCustomSkillParams{ + SkillID: id, + Type: anthropic.BetaManagedAgentsCustomSkillParamsTypeCustom, + }, + }) + } + return out } // UploadDir uploads the contents of dir as one skill. dir must contain // SKILL.md at its root; that file's YAML frontmatter is parsed for // name/description so we can persist them locally. // -// Re-uploading a directory whose SKILL.md `name` already exists in our -// DB updates the existing row (single Anthropic skill with new versions -// over time) instead of inserting a duplicate. +// If a skill with the same `name` already exists in our DB we push a +// NEW VERSION onto the existing Anthropic skill instead of creating a +// new one — Anthropic enforces uniqueness on display_title, and +// re-issuing Skills.New would 400 with "Skill cannot reuse an existing +// display_title". func (s *Service) UploadDir(ctx context.Context, dir string) (Skill, error) { dir = filepath.Clean(dir) meta, err := readSkillMD(dir) @@ -146,12 +216,27 @@ func (s *Service) UploadDir(ctx context.Context, dir string) (Skill, error) { } }() - resp, err := s.client.Beta.Skills.New(ctx, anthropic.BetaSkillNewParams{ - DisplayTitle: param.NewOpt(meta.Name), - Files: files, - }) + existingID, err := s.lookupAnthropicID(ctx, meta.Name) if err != nil { - return Skill{}, fmt.Errorf("anthropic upload: %w", err) + return Skill{}, err + } + + var anthropicID, version, source string + if existingID != "" { + v, err := s.client.Beta.Skills.Versions.New(ctx, existingID, anthropic.BetaSkillVersionNewParams{Files: files}) + if err != nil { + return Skill{}, fmt.Errorf("anthropic upload (new version): %w", err) + } + anthropicID, version, source = existingID, v.Version, "custom" + } else { + resp, err := s.client.Beta.Skills.New(ctx, anthropic.BetaSkillNewParams{ + DisplayTitle: param.NewOpt(meta.Name), + Files: files, + }) + if err != nil { + return Skill{}, fmt.Errorf("anthropic upload: %w", err) + } + anthropicID, version, source = resp.ID, resp.LatestVersion, resp.Source } now := time.Now().UTC() @@ -165,14 +250,69 @@ func (s *Service) UploadDir(ctx context.Context, dir string) (Skill, error) { version = EXCLUDED.version, updated_at = EXCLUDED.updated_at RETURNING id, anthropic_skill_id, name, description, source, version, created_at, updated_at - `, resp.ID, meta.Name, meta.Description, resp.Source, resp.LatestVersion, now). + `, anthropicID, meta.Name, meta.Description, source, version, now). Scan(&sk.ID, &sk.AnthropicID, &sk.Name, &sk.Description, &sk.Source, &sk.Version, &sk.CreatedAt, &sk.UpdatedAt) if err != nil { return Skill{}, fmt.Errorf("persist skill: %w", err) } + s.refreshAgentsAsync() return sk, nil } +func (s *Service) lookupAnthropicID(ctx context.Context, name string) (string, error) { + var id string + err := s.pool.QueryRow(ctx, `SELECT anthropic_skill_id FROM skills WHERE name = $1`, name).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return id, err +} + +// refreshAgentsAsync pushes the current skill list to every per-user +// agent that's already been provisioned. Without this, uploading a new +// skill would only reach NEW users — existing users have a cached +// agent_id whose Skills field never changes after creation. +// +// Fired-and-forgotten so the upload latency stays low. Errors are +// logged; the next chat session would still surface them. +func (s *Service) refreshAgentsAsync() { + go func() { + ctx := context.Background() + ids, err := s.AnthropicIDs(ctx) + if err != nil { + log.Printf("skills refresh: list ids: %v", err) + return + } + skillUnion := SkillParams(ids) + rows, err := s.pool.Query(ctx, ` + SELECT anthropic_agent_id FROM users + WHERE anthropic_agent_id IS NOT NULL AND anthropic_agent_id <> ''`) + if err != nil { + log.Printf("skills refresh: query users: %v", err) + return + } + defer rows.Close() + var updated, failed int + for rows.Next() { + var aid string + if err := rows.Scan(&aid); err != nil { + continue + } + if _, err := s.client.Beta.Agents.Update(ctx, aid, anthropic.BetaAgentUpdateParams{ + Skills: skillUnion, + }); err != nil { + log.Printf("skills refresh: update %s: %v", aid, err) + failed++ + continue + } + updated++ + } + if updated+failed > 0 { + log.Printf("skills refresh: %d agents updated, %d failed", updated, failed) + } + }() +} + // SyncDirs walks each path in roots, finds every immediate child folder // containing a SKILL.md, and uploads it. Folders without SKILL.md are // skipped with a logged warning. Returns the list of skills that From 859203f6848a5965ef05ce295445644f11e1e047 Mon Sep 17 00:00:00 2001 From: Brendan Playford <34052452+teslashibe@users.noreply.github.com> Date: Sun, 10 May 2026 17:40:54 -0700 Subject: [PATCH 6/9] feat(skills): external MCP server per skill (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills can now ship their own MCP server via a `skill.yaml` file. The host discovers, health-checks, and wires these into the per-user agent alongside the built-in engagement server — the agent gains the skill's tools without recompiling the backend. - skill.yaml parsing: transport (http/stdio), url, command, image - 00006_skill_mcp_servers.sql: persists the server config per skill with CASCADE DELETE on the parent skills row - Health check: smoke-tests tools/list before marking healthy - HealthyMCPServers / RecheckHealth queries for the provisioner and a future admin endpoint - Provisioner (provision.go): builds the MCPServers list dynamically from engagement + healthy skill servers. Enforces the 10-server cap with a logged warning on overflow. - docker-compose.skills.yml overlay for running skill sidecars - docs/SKILLS.md documents the pattern, layout, and constraints The in-process astrology demo stays as a working example; extracting it to its own repo + MCP server is tracked in #37 step 6. Co-authored-by: Cursor --- backend/cmd/server/main.go | 16 ++ backend/internal/agent/provision.go | 102 ++++++--- .../db/migrations/00006_skill_mcp_servers.sql | 24 ++ backend/internal/skills/mcp.go | 214 ++++++++++++++++++ backend/internal/skills/skills.go | 32 +++ docker-compose.skills.yml | 29 +++ docs/SKILLS.md | 55 +++++ 7 files changed, 442 insertions(+), 30 deletions(-) create mode 100644 backend/internal/db/migrations/00006_skill_mcp_servers.sql create mode 100644 backend/internal/skills/mcp.go create mode 100644 docker-compose.skills.yml diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 9148c78..8eb3f83 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -341,6 +341,22 @@ func mountMCP( } provOpts := agent.ProvisionerOptions{ SkillIDs: skillsSvc.AnthropicIDs, + SkillMCPServers: func(ctx context.Context) ([]agent.SkillMCPServerEntry, error) { + servers, err := skillsSvc.HealthyMCPServers(ctx) + if err != nil { + return nil, err + } + out := make([]agent.SkillMCPServerEntry, 0, len(servers)) + for _, s := range servers { + name := s.URL // default to URL as name + // Try to look up the skill name for a cleaner MCPServer name + if sk, err := skillsSvc.SkillByID(ctx, s.SkillID); err == nil && sk != nil { + name = sk.Name + } + out = append(out, agent.SkillMCPServerEntry{Name: name, URL: s.URL}) + } + return out, nil + }, } if cfg.NotificationsEnabled { provOpts.SystemPrompt = agent.NotificationsSystemPrompt() diff --git a/backend/internal/agent/provision.go b/backend/internal/agent/provision.go index c3ba9ea..70ed019 100644 --- a/backend/internal/agent/provision.go +++ b/backend/internal/agent/provision.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "net/url" "sync" "time" @@ -37,6 +38,18 @@ type MCPEndpointFn func(ctx context.Context, userID string) (mcpURL string, err // return an empty slice when no skills are installed. type SkillIDsFn func(ctx context.Context) ([]string, error) +// SkillMCPServerEntry is the minimal info the provisioner needs to add +// a skill's external MCP server to the agent. Matches the shape +// returned by skills.HealthyMCPServers. +type SkillMCPServerEntry struct { + Name string // used as the MCPServer name (e.g. "astrology") + URL string +} + +// SkillMCPServersFn returns the healthy skill MCP servers to wire into +// the agent alongside the built-in engagement server. +type SkillMCPServersFn func(ctx context.Context) ([]SkillMCPServerEntry, error) + // Provisioner lazily creates per-user Anthropic Agent + Environment // resources, caches them in the users table, and returns the cached pair on // subsequent calls. @@ -46,13 +59,14 @@ type SkillIDsFn func(ctx context.Context) ([]string, error) // in main.go and call EnsureForUser before any code that previously read // cfg.AnthropicAgentID. type Provisioner struct { - cfg config.Config - client anthropic.Client - pool *pgxpool.Pool - endpoint MCPEndpointFn - model anthropic.BetaManagedAgentsModel - system string - skillIDs SkillIDsFn + cfg config.Config + client anthropic.Client + pool *pgxpool.Pool + endpoint MCPEndpointFn + model anthropic.BetaManagedAgentsModel + system string + skillIDs SkillIDsFn + skillMCPServers SkillMCPServersFn mu sync.Mutex inflight map[string]*sync.Mutex @@ -69,6 +83,9 @@ type ProvisionerOptions struct { // agents. When non-nil, agents are also given the code-execution // tool so they can read the skills' SKILL.md files at runtime. SkillIDs SkillIDsFn + // SkillMCPServers returns the healthy external MCP servers from + // installed skills to wire alongside the built-in engagement server. + SkillMCPServers SkillMCPServersFn } // NewProvisioner constructs a Provisioner. endpoint is required. @@ -85,14 +102,15 @@ func NewProvisioner(cfg config.Config, client anthropic.Client, pool *pgxpool.Po system = defaultSystemPrompt } return &Provisioner{ - cfg: cfg, - client: client, - pool: pool, - endpoint: endpoint, - model: model, - system: system, - skillIDs: opts.SkillIDs, - inflight: map[string]*sync.Mutex{}, + cfg: cfg, + client: client, + pool: pool, + endpoint: endpoint, + model: model, + system: system, + skillIDs: opts.SkillIDs, + skillMCPServers: opts.SkillMCPServers, + inflight: map[string]*sync.Mutex{}, }, nil } @@ -249,7 +267,13 @@ func (p *Provisioner) createAgent(ctx context.Context, userID string) (string, e return "", fmt.Errorf("MCP_PUBLIC_URL/APP_URL resolves to loopback (%s); for local dev, expose via ngrok or cloudflared and set MCP_PUBLIC_URL to the public URL", host) } const mcpServerName = "engagement" + const maxMCPServers = 10 // Anthropic per-agent cap + mcpServers := []anthropic.BetaManagedAgentsURLMCPServerParams{{ + Name: mcpServerName, + Type: anthropic.BetaManagedAgentsURLMCPServerParamsTypeURL, + URL: mcpURL, + }} tools := []anthropic.BetaAgentNewParamsToolUnion{ {OfMCPToolset: &anthropic.BetaManagedAgentsMCPToolsetParams{ MCPServerName: mcpServerName, @@ -257,14 +281,36 @@ func (p *Provisioner) createAgent(ctx context.Context, userID string) (string, e }}, } - // Skills require code-execution because Claude reads SKILL.md - // (and any bundled scripts) via bash inside the sandbox. We - // attach the agent toolset (which includes bash + code exec) - // alongside the existing MCP toolset only when at least one - // skill is configured — keeps the default permission surface - // unchanged for forks that don't use skills. - skills, _ := p.resolveSkills(ctx) - if len(skills) > 0 { + // Wire healthy skill MCP servers (each gets its own MCPServer + + // MCPToolset entry so the agent can route tool calls to them). + if p.skillMCPServers != nil { + entries, err := p.skillMCPServers(ctx) + if err != nil { + log.Printf("agent: resolve skill MCP servers: %v (continuing without them)", err) + } + for _, e := range entries { + if len(mcpServers) >= maxMCPServers { + log.Printf("agent: %d MCP server cap reached, skipping skill %q", maxMCPServers, e.Name) + break + } + mcpServers = append(mcpServers, anthropic.BetaManagedAgentsURLMCPServerParams{ + Name: e.Name, + Type: anthropic.BetaManagedAgentsURLMCPServerParamsTypeURL, + URL: e.URL, + }) + tools = append(tools, anthropic.BetaAgentNewParamsToolUnion{ + OfMCPToolset: &anthropic.BetaManagedAgentsMCPToolsetParams{ + MCPServerName: e.Name, + Type: anthropic.BetaManagedAgentsMCPToolsetParamsTypeMCPToolset, + }, + }) + } + } + + // Skills (instruction bundles) require code-execution because + // Claude reads SKILL.md via bash inside the sandbox. + skillParams, _ := p.resolveSkills(ctx) + if len(skillParams) > 0 { tools = append(tools, anthropic.BetaAgentNewParamsToolUnion{ OfAgentToolset20260401: &anthropic.BetaManagedAgentsAgentToolset20260401Params{ Type: anthropic.BetaManagedAgentsAgentToolset20260401ParamsTypeAgentToolset20260401, @@ -279,13 +325,9 @@ func (p *Provisioner) createAgent(ctx context.Context, userID string) (string, e Model: anthropic.BetaManagedAgentsModelConfigParams{ ID: p.model, }, - MCPServers: []anthropic.BetaManagedAgentsURLMCPServerParams{{ - Name: mcpServerName, - Type: anthropic.BetaManagedAgentsURLMCPServerParamsTypeURL, - URL: mcpURL, - }}, - Tools: tools, - Skills: skills, + MCPServers: mcpServers, + Tools: tools, + Skills: skillParams, } a, err := p.client.Beta.Agents.New(ctx, params) diff --git a/backend/internal/db/migrations/00006_skill_mcp_servers.sql b/backend/internal/db/migrations/00006_skill_mcp_servers.sql new file mode 100644 index 0000000..d1c2c3e --- /dev/null +++ b/backend/internal/db/migrations/00006_skill_mcp_servers.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE TABLE IF NOT EXISTS skill_mcp_servers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + transport TEXT NOT NULL DEFAULT 'http', + url TEXT, + command TEXT[], + image TEXT, + healthy BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(skill_id) +); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS skill_mcp_servers; + +-- +goose StatementEnd diff --git a/backend/internal/skills/mcp.go b/backend/internal/skills/mcp.go new file mode 100644 index 0000000..29faf50 --- /dev/null +++ b/backend/internal/skills/mcp.go @@ -0,0 +1,214 @@ +package skills + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "gopkg.in/yaml.v3" +) + +// SkillMCPServer is the row from skill_mcp_servers. +type SkillMCPServer struct { + ID string `json:"id"` + SkillID string `json:"skill_id"` + Transport string `json:"transport"` // "http" | "stdio" + URL string `json:"url,omitempty"` + Command []string `json:"command,omitempty"` + Image string `json:"image,omitempty"` + Healthy bool `json:"healthy"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// skillYAML is the on-disk shape of skill.yaml. +type skillYAML struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + MCPServer *skillMCPEntry `yaml:"mcp_server"` +} + +type skillMCPEntry struct { + Transport string `yaml:"transport"` // "http" | "stdio" + URL string `yaml:"url"` + Command []string `yaml:"command"` + Image string `yaml:"image"` +} + +// readSkillYAML returns nil (no error) when the file doesn't exist — +// skills without compute don't need one. +func readSkillYAML(dir string) (*skillYAML, error) { + raw, err := os.ReadFile(filepath.Join(dir, "skill.yaml")) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var sy skillYAML + if err := yaml.Unmarshal(raw, &sy); err != nil { + return nil, fmt.Errorf("parse skill.yaml: %w", err) + } + return &sy, nil +} + +func (e *skillMCPEntry) validate() error { + e.Transport = strings.ToLower(strings.TrimSpace(e.Transport)) + if e.Transport == "" { + e.Transport = "http" + } + switch e.Transport { + case "http": + if strings.TrimSpace(e.URL) == "" { + return fmt.Errorf("skill.yaml: mcp_server.url is required for http transport") + } + case "stdio": + if len(e.Command) == 0 { + return fmt.Errorf("skill.yaml: mcp_server.command is required for stdio transport") + } + default: + return fmt.Errorf("skill.yaml: unsupported transport %q (use 'http' or 'stdio')", e.Transport) + } + return nil +} + +// upsertMCPServer persists the skill's MCP server config. Called from +// UploadDir after the Anthropic upload succeeds. +func (s *Service) upsertMCPServer(ctx context.Context, skillID string, entry *skillMCPEntry) (*SkillMCPServer, error) { + if entry == nil { + return nil, nil + } + if err := entry.validate(); err != nil { + return nil, err + } + + healthy := false + if entry.Transport == "http" { + healthy = healthCheck(ctx, entry.URL) + } + + now := time.Now().UTC() + var row SkillMCPServer + err := s.pool.QueryRow(ctx, ` + INSERT INTO skill_mcp_servers (skill_id, transport, url, command, image, healthy, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + ON CONFLICT (skill_id) DO UPDATE + SET transport = EXCLUDED.transport, + url = EXCLUDED.url, + command = EXCLUDED.command, + image = EXCLUDED.image, + healthy = EXCLUDED.healthy, + updated_at = EXCLUDED.updated_at + RETURNING id, skill_id, transport, url, command, image, healthy, created_at, updated_at + `, skillID, entry.Transport, entry.URL, entry.Command, entry.Image, healthy, now). + Scan(&row.ID, &row.SkillID, &row.Transport, &row.URL, &row.Command, &row.Image, &row.Healthy, &row.CreatedAt, &row.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("upsert skill_mcp_servers: %w", err) + } + return &row, nil +} + +// HealthyMCPServers returns every reachable skill MCP server. +func (s *Service) HealthyMCPServers(ctx context.Context) ([]SkillMCPServer, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, skill_id, transport, url, command, image, healthy, created_at, updated_at + FROM skill_mcp_servers + WHERE healthy = true AND transport = 'http' + ORDER BY created_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []SkillMCPServer{} + for rows.Next() { + var r SkillMCPServer + if err := rows.Scan(&r.ID, &r.SkillID, &r.Transport, &r.URL, &r.Command, &r.Image, &r.Healthy, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// RecheckHealth re-runs the health check against every http-transport +// server and updates the `healthy` flag. Useful on server startup or +// from a cron/admin endpoint. +func (s *Service) RecheckHealth(ctx context.Context) (int, int, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, url FROM skill_mcp_servers WHERE transport = 'http'`) + if err != nil { + return 0, 0, err + } + defer rows.Close() + var ok, fail int + for rows.Next() { + var id, u string + if err := rows.Scan(&id, &u); err != nil { + continue + } + h := healthCheck(ctx, u) + if _, err := s.pool.Exec(ctx, `UPDATE skill_mcp_servers SET healthy = $1, updated_at = NOW() WHERE id = $2`, h, id); err != nil { + continue + } + if h { + ok++ + } else { + fail++ + } + } + return ok, fail, rows.Err() +} + +// healthCheck sends a JSON-RPC `tools/list` to the skill's MCP server +// and returns true if we get a valid response within 5 seconds. +func healthCheck(ctx context.Context, mcpURL string) bool { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + req, err := http.NewRequestWithContext(ctx, http.MethodPost, mcpURL, strings.NewReader(body)) + if err != nil { + return false + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return false + } + var rpc struct { + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&rpc); err != nil { + return false + } + return rpc.Result != nil && rpc.Error == nil +} + +// skillMCPServerForSkill returns the MCP server row for a given +// skill_id, or nil if none exists. +func (s *Service) skillMCPServerForSkill(ctx context.Context, skillID string) (*SkillMCPServer, error) { + var r SkillMCPServer + err := s.pool.QueryRow(ctx, ` + SELECT id, skill_id, transport, url, command, image, healthy, created_at, updated_at + FROM skill_mcp_servers WHERE skill_id = $1`, skillID). + Scan(&r.ID, &r.SkillID, &r.Transport, &r.URL, &r.Command, &r.Image, &r.Healthy, &r.CreatedAt, &r.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &r, nil +} diff --git a/backend/internal/skills/skills.go b/backend/internal/skills/skills.go index 4549b7b..2f907c7 100644 --- a/backend/internal/skills/skills.go +++ b/backend/internal/skills/skills.go @@ -64,6 +64,23 @@ func NewService(client anthropic.Client, pool *pgxpool.Pool) *Service { return &Service{client: client, pool: pool} } +// SkillByID returns the skill row by its internal UUID. Used by the +// provisioner to look up a human-friendly name for MCP server entries. +func (s *Service) SkillByID(ctx context.Context, id string) (*Skill, error) { + var sk Skill + err := s.pool.QueryRow(ctx, ` + SELECT id, anthropic_skill_id, name, description, source, version, created_at, updated_at + FROM skills WHERE id = $1`, id). + Scan(&sk.ID, &sk.AnthropicID, &sk.Name, &sk.Description, &sk.Source, &sk.Version, &sk.CreatedAt, &sk.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &sk, nil +} + // List returns every skill we've persisted, newest first. func (s *Service) List(ctx context.Context) ([]Skill, error) { rows, err := s.pool.Query(ctx, ` @@ -255,6 +272,21 @@ func (s *Service) UploadDir(ctx context.Context, dir string) (Skill, error) { if err != nil { return Skill{}, fmt.Errorf("persist skill: %w", err) } + + // If the skill ships a skill.yaml with mcp_server config, persist + // it so the provisioner can attach the skill's MCP server alongside + // the built-in engagement server. + sy, err := readSkillYAML(dir) + if err != nil { + log.Printf("skills: warning: %s has invalid skill.yaml: %v (skill uploaded but MCP server not registered)", meta.Name, err) + } else if sy != nil && sy.MCPServer != nil { + if mcpRow, err := s.upsertMCPServer(ctx, sk.ID, sy.MCPServer); err != nil { + log.Printf("skills: warning: %s MCP server registration failed: %v", meta.Name, err) + } else if mcpRow != nil { + log.Printf("skills: %s MCP server registered (transport=%s healthy=%v)", meta.Name, mcpRow.Transport, mcpRow.Healthy) + } + } + s.refreshAgentsAsync() return sk, nil } diff --git a/docker-compose.skills.yml b/docker-compose.skills.yml new file mode 100644 index 0000000..a88adf2 --- /dev/null +++ b/docker-compose.skills.yml @@ -0,0 +1,29 @@ +# docker-compose.skills.yml — overlay for skill MCP sidecars. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.skills.yml up --build -d +# +# Each skill that ships a skill.yaml with mcp_server.transport: http +# gets a sidecar service here. The URL in skill.yaml should match the +# service name + port defined below (e.g. http://astrology:9090/mcp/v1). +# +# This overlay is NOT required for skills that don't have compute (pure +# SKILL.md + reference files). Only skills with their own MCP server +# need a sidecar. + +services: + # Example: astrology skill sidecar + # Uncomment and adjust when teslashibe/astrology-skill ships its own + # MCP server. For now the astrology tool runs in-process (platforms/astrology.go). + # + # astrology: + # image: ghcr.io/teslashibe/astrology-skill:latest + # # or build locally: + # # build: ./skills/astrology/mcp + # ports: + # - "9090:9090" + # healthcheck: + # test: ["CMD", "curl", "-sf", "http://localhost:9090/health"] + # interval: 10s + # timeout: 3s + # retries: 3 diff --git a/docs/SKILLS.md b/docs/SKILLS.md index e0251a4..49931de 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -131,3 +131,58 @@ SKILLS_AGENT_IDS=skill_abc,skill_def ``` Most setups leave this empty and let the runtime per-user provisioner attach whatever skills the workspace currently has. + +## External MCP servers per skill + +Skills that need compute (native deps, data files, side effects) can ship their own MCP server as a standalone process. The host discovers and connects to it automatically — the agent gains the skill's tools without recompiling the backend. + +### How it works + +A skill ships a `skill.yaml` alongside its `SKILL.md`: + +```yaml +name: astrology +description: Birth charts and transit forecasts. +mcp_server: + transport: http + url: http://astrology:9090/mcp/v1 + image: ghcr.io/teslashibe/astrology-skill:latest +``` + +When `make skills-sync` (or the upload API) processes this skill: +1. SKILL.md + reference files are uploaded to Anthropic (instructions layer, unchanged) +2. `skill.yaml`'s `mcp_server` block is parsed and persisted in the `skill_mcp_servers` table +3. A health check (`tools/list`) runs against the URL; `healthy = true` on success +4. The per-user agent provisioner attaches the skill's MCP server alongside the built-in `engagement` server + +### Running skill sidecars + +Use the `docker-compose.skills.yml` overlay: + +```bash +docker compose -f docker-compose.yml -f docker-compose.skills.yml up --build -d +``` + +Each skill with an MCP server gets its own service entry. The URL in `skill.yaml` should match the Docker Compose service name. + +### Constraints + +- **10 MCP servers per agent** (Anthropic cap). 1 is always `engagement`; 9 available for skills. +- **URL transport only for Managed Agents** (Anthropic constraint). The `stdio` transport field is reserved for future Claude Code / Desktop integration. +- Skill MCP servers must be reachable from Anthropic's cloud (same constraint as the host `engagement` server). +- For local dev, skill servers run as Docker Compose services or on `host.docker.internal`. + +### Skill folder layout (with MCP server) + +``` +my-skill/ +├── SKILL.md ← required; uploaded to Anthropic +├── reference.md ← optional; uploaded to Anthropic +├── skill.yaml ← declares the MCP server +├── mcp/ ← the MCP server (any language) +│ ├── main.go +│ ├── go.mod +│ └── Dockerfile +└── README.md +``` + From c56962a9fb79323f356172a27fbaf7102653d8a2 Mon Sep 17 00:00:00 2001 From: Brendan Playford <34052452+teslashibe@users.noreply.github.com> Date: Sun, 10 May 2026 17:46:01 -0700 Subject: [PATCH 7/9] feat(skills): extract astrology to standalone MCP server repo (#37 step 6+8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove internal/astrology/ and platforms/astrology.go from the template. The astrology compute now lives in teslashibe/astrology-skill as a standalone Go MCP server wrapping the real pyswisseph chart.py. - Drop Astrology() from platforms.All() - Update docker-compose.skills.yml with a real sidecar entry that builds from ../astrology-skill - Delete skills/astrology/ (bundled slim skill) — the canonical source is now the astrology-skill repo itself Template ships zero domain-specific tools. All 14 social platforms stay in-process (shared auth/rate-limits/credential storage). New capabilities come from external skill repos with their own MCP servers. Co-authored-by: Cursor --- backend/internal/astrology/astrology.go | 170 -------------------- backend/internal/mcp/platforms/astrology.go | 73 --------- backend/internal/mcp/platforms/platforms.go | 1 - docker-compose.skills.yml | 32 ++-- skills/astrology/SKILL.md | 42 ----- skills/astrology/reference.md | 61 ------- 6 files changed, 12 insertions(+), 367 deletions(-) delete mode 100644 backend/internal/astrology/astrology.go delete mode 100644 backend/internal/mcp/platforms/astrology.go delete mode 100644 skills/astrology/SKILL.md delete mode 100644 skills/astrology/reference.md diff --git a/backend/internal/astrology/astrology.go b/backend/internal/astrology/astrology.go deleted file mode 100644 index 685abcf..0000000 --- a/backend/internal/astrology/astrology.go +++ /dev/null @@ -1,170 +0,0 @@ -// Package astrology is a small natal-chart computation engine sized for -// the Skill demonstration: pure Go, no CGO, no ephemeris data files. -// -// Scope (deliberately narrow): -// -// - Sun sign: COMPUTED CORRECTLY from birth date. -// - Element + modality: derived from the sun sign. -// - Birth data echo: returned for the agent to reference in its -// reading. -// -// What this is NOT: -// -// - A full natal chart (no moon, ascendant, houses, aspects, transits). -// For those you need a real ephemeris (Swiss Ephemeris via -// mshafiee/swephgo, or a Meeus implementation like soniakeys/meeus). -// Swap [Compute] for a real engine when the fork needs it; the MCP -// tool surface (and the calling SKILL.md) does not need to change. -// -// This pattern — skill instructions in Anthropic, compute server-side -// via MCP — is documented in docs/SKILLS.md. -package astrology - -import ( - "fmt" - "strings" - "time" -) - -// Chart is the JSON the MCP tool returns to the agent. -type Chart struct { - Birth BirthData `json:"birth"` - Sun Placement `json:"sun"` - Notes []string `json:"notes"` -} - -type BirthData struct { - DateUTC string `json:"date_utc"` // ISO-8601 in UTC - Lat float64 `json:"lat,omitempty"` - Lon float64 `json:"lon,omitempty"` - Place string `json:"place,omitempty"` -} - -type Placement struct { - Sign string `json:"sign"` // e.g. "Aries" - Symbol string `json:"symbol"` // e.g. "♈" - Element string `json:"element"` // Fire/Earth/Air/Water - Modality string `json:"modality"` // Cardinal/Fixed/Mutable - Polarity string `json:"polarity"` // Masculine/Feminine - Ruler string `json:"ruler"` // e.g. "Mars" -} - -// Compute returns the chart for the given UTC birth instant + location. -// place is optional — used only to echo back to the agent. -func Compute(birthUTC time.Time, lat, lon float64, place string) Chart { - sun := sunSign(birthUTC) - notes := []string{ - "Sun sign computed from civil calendar date in UTC.", - "Moon, ascendant, houses, aspects, and transits are not computed by this stub. Wire in a Swiss Ephemeris implementation to enable them.", - } - return Chart{ - Birth: BirthData{ - DateUTC: birthUTC.UTC().Format(time.RFC3339), - Lat: lat, - Lon: lon, - Place: strings.TrimSpace(place), - }, - Sun: sun, - Notes: notes, - } -} - -// Tropical zodiac date ranges (UTC). Boundaries vary by ~1 day across -// years due to the Sun's motion; the table below uses canonical -// astrological dates which match within ±1 day for almost every -// 20th–21st century birth. For exact boundaries swap in a real -// ephemeris. -var signTable = []struct { - name, symbol, element, modality, polarity, ruler string - start, end monthDay -}{ - {"Capricorn", "♑", "Earth", "Cardinal", "Feminine", "Saturn", monthDay{12, 22}, monthDay{1, 19}}, - {"Aquarius", "♒", "Air", "Fixed", "Masculine", "Saturn", monthDay{1, 20}, monthDay{2, 18}}, - {"Pisces", "♓", "Water", "Mutable", "Feminine", "Jupiter", monthDay{2, 19}, monthDay{3, 20}}, - {"Aries", "♈", "Fire", "Cardinal", "Masculine", "Mars", monthDay{3, 21}, monthDay{4, 19}}, - {"Taurus", "♉", "Earth", "Fixed", "Feminine", "Venus", monthDay{4, 20}, monthDay{5, 20}}, - {"Gemini", "♊", "Air", "Mutable", "Masculine", "Mercury", monthDay{5, 21}, monthDay{6, 20}}, - {"Cancer", "♋", "Water", "Cardinal", "Feminine", "Moon", monthDay{6, 21}, monthDay{7, 22}}, - {"Leo", "♌", "Fire", "Fixed", "Masculine", "Sun", monthDay{7, 23}, monthDay{8, 22}}, - {"Virgo", "♍", "Earth", "Mutable", "Feminine", "Mercury", monthDay{8, 23}, monthDay{9, 22}}, - {"Libra", "♎", "Air", "Cardinal", "Masculine", "Venus", monthDay{9, 23}, monthDay{10, 22}}, - {"Scorpio", "♏", "Water", "Fixed", "Feminine", "Pluto", monthDay{10, 23}, monthDay{11, 21}}, - {"Sagittarius", "♐", "Fire", "Mutable", "Masculine", "Jupiter", monthDay{11, 22}, monthDay{12, 21}}, -} - -type monthDay struct{ m, d int } - -func (a monthDay) gte(b monthDay) bool { - if a.m != b.m { - return a.m > b.m - } - return a.d >= b.d -} -func (a monthDay) lte(b monthDay) bool { - if a.m != b.m { - return a.m < b.m - } - return a.d <= b.d -} - -func sunSign(t time.Time) Placement { - md := monthDay{int(t.UTC().Month()), t.UTC().Day()} - for _, s := range signTable { - // Capricorn wraps the year; handle as two ranges. - if s.start.m == 12 && s.end.m == 1 { - if md.gte(s.start) || md.lte(s.end) { - return placementOf(s) - } - continue - } - if md.gte(s.start) && md.lte(s.end) { - return placementOf(s) - } - } - // Should not happen; defensive default. - return Placement{Sign: "Unknown"} -} - -func placementOf(s struct { - name, symbol, element, modality, polarity, ruler string - start, end monthDay -}) Placement { - return Placement{ - Sign: s.name, - Symbol: s.symbol, - Element: s.element, - Modality: s.modality, - Polarity: s.polarity, - Ruler: s.ruler, - } -} - -// ParseBirthDateTime parses the agent's input shape: an ISO-8601 date -// (e.g. "1990-05-12") plus optional 24h time ("14:32") and IANA tz -// ("America/New_York"). Returns the moment in UTC. Tz defaults to UTC -// when blank; time defaults to noon (12:00) when blank to keep the sun -// sign correct on cusp dates without requiring the user to remember -// their birth time. -func ParseBirthDateTime(date, hhmm, tz string) (time.Time, error) { - date = strings.TrimSpace(date) - if date == "" { - return time.Time{}, fmt.Errorf("date is required (YYYY-MM-DD)") - } - hhmm = strings.TrimSpace(hhmm) - if hhmm == "" { - hhmm = "12:00" - } - tz = strings.TrimSpace(tz) - if tz == "" { - tz = "UTC" - } - loc, err := time.LoadLocation(tz) - if err != nil { - return time.Time{}, fmt.Errorf("unknown timezone %q: %w", tz, err) - } - t, err := time.ParseInLocation("2006-01-02 15:04", date+" "+hhmm, loc) - if err != nil { - return time.Time{}, fmt.Errorf("invalid date/time: %w", err) - } - return t.UTC(), nil -} diff --git a/backend/internal/mcp/platforms/astrology.go b/backend/internal/mcp/platforms/astrology.go deleted file mode 100644 index 60c76b6..0000000 --- a/backend/internal/mcp/platforms/astrology.go +++ /dev/null @@ -1,73 +0,0 @@ -package platforms - -import ( - "context" - "encoding/json" - - "github.com/teslashibe/mcptool" - - "github.com/teslashibe/agent-setup/backend/internal/astrology" - "github.com/teslashibe/agent-setup/backend/internal/credentials" - "github.com/teslashibe/agent-setup/backend/internal/mcp" -) - -// Astrology binds the in-process astrology engine to MCP. Demonstrates -// the "skill calls home" pattern documented in docs/SKILLS.md: the -// uploaded SKILL.md (~/skills/astrology) tells Claude to invoke -// astrology_birth_summary, the heavy compute (which would need a real -// ephemeris with bundled binary data — disallowed in Anthropic's -// sandbox) runs here in our backend instead. -// -// Credentialless: nothing to authenticate. Validator returns nil so the -// tool is always available without any per-user setup. -func Astrology() Plugin { - return Plugin{ - Binding: mcp.PlatformBinding{ - Provider: astrologyProvider{}, - NewClient: func(_ context.Context, _ json.RawMessage) (any, error) { return astrologyClient{}, nil }, - NoCredentials: true, // skip the credentials lookup entirely - }, - Validator: nullValidator{platform: "astrology"}, - } -} - -type astrologyProvider struct{} -type astrologyClient struct{} - -func (astrologyProvider) Platform() string { return "astrology" } - -func (astrologyProvider) Tools() []mcptool.Tool { - return []mcptool.Tool{birthSummaryTool} -} - -// birthSummaryInput is the shape the agent passes. Date is the only -// required field; time + tz default sensibly so a user who only knows -// their birthday still gets an accurate sun sign. -type birthSummaryInput struct { - Date string `json:"date" jsonschema:"required" jsonschema_description:"Birth date in ISO-8601 (YYYY-MM-DD)."` - Time string `json:"time,omitempty" jsonschema_description:"24h local time (HH:MM). Defaults to 12:00 when omitted."` - Timezone string `json:"timezone,omitempty" jsonschema_description:"IANA timezone (e.g. America/New_York). Defaults to UTC when omitted."` - Lat float64 `json:"lat,omitempty" jsonschema_description:"Birth location latitude in decimal degrees (positive = north)."` - Lon float64 `json:"lon,omitempty" jsonschema_description:"Birth location longitude in decimal degrees (positive = east)."` - Place string `json:"place,omitempty" jsonschema_description:"Free-text place name (echoed back; does not affect computation)."` -} - -var birthSummaryTool = mcptool.Define[astrologyClient, birthSummaryInput]( - "astrology_birth_summary", - "Compute sun sign, element, modality, and ruler from a birth date. Use for chart/reading/sun-sign requests.", - "", - func(_ context.Context, _ astrologyClient, in birthSummaryInput) (any, error) { - t, err := astrology.ParseBirthDateTime(in.Date, in.Time, in.Timezone) - if err != nil { - return nil, &mcptool.Error{Code: "invalid_input", Message: err.Error()} - } - return astrology.Compute(t, in.Lat, in.Lon, in.Place), nil - }, -) - -// Re-declare what we need from the existing helpers so this file -// stays self-contained. nullValidator is defined in platforms.go and -// already accepts every platform name; we don't redeclare it here. -// -// Compile-time assertion that Plugin's wiring is complete. -var _ = credentials.Validator(nullValidator{}) diff --git a/backend/internal/mcp/platforms/platforms.go b/backend/internal/mcp/platforms/platforms.go index 92b89b9..aff11d1 100644 --- a/backend/internal/mcp/platforms/platforms.go +++ b/backend/internal/mcp/platforms/platforms.go @@ -80,7 +80,6 @@ func All() []Plugin { Nextdoor(), ElevenLabs(), Codegen(), - Astrology(), } } diff --git a/docker-compose.skills.yml b/docker-compose.skills.yml index a88adf2..eebed8f 100644 --- a/docker-compose.skills.yml +++ b/docker-compose.skills.yml @@ -5,25 +5,17 @@ # # Each skill that ships a skill.yaml with mcp_server.transport: http # gets a sidecar service here. The URL in skill.yaml should match the -# service name + port defined below (e.g. http://astrology:9090/mcp/v1). -# -# This overlay is NOT required for skills that don't have compute (pure -# SKILL.md + reference files). Only skills with their own MCP server -# need a sidecar. +# service name + port (e.g. http://astrology:9090/mcp/v1). services: - # Example: astrology skill sidecar - # Uncomment and adjust when teslashibe/astrology-skill ships its own - # MCP server. For now the astrology tool runs in-process (platforms/astrology.go). - # - # astrology: - # image: ghcr.io/teslashibe/astrology-skill:latest - # # or build locally: - # # build: ./skills/astrology/mcp - # ports: - # - "9090:9090" - # healthcheck: - # test: ["CMD", "curl", "-sf", "http://localhost:9090/health"] - # interval: 10s - # timeout: 3s - # retries: 3 + astrology: + build: + context: ../astrology-skill + dockerfile: Dockerfile + ports: + - "9090:9090" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:9090/health"] + interval: 10s + timeout: 3s + retries: 3 diff --git a/skills/astrology/SKILL.md b/skills/astrology/SKILL.md deleted file mode 100644 index b133173..0000000 --- a/skills/astrology/SKILL.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: astrology -description: Generate a natal birth-chart reading from the user's birth date (and optionally time, timezone, and location). Use when the user asks for a chart, reading, sun sign, or asks to interpret an astrological placement. The skill calls the astrology_birth_summary MCP tool for the computed values, then composes the reading from the included reference table. ---- - -# Astrology - -You have access to a backend tool that does the math, plus a reference table that does the interpretation. Your job is to gather the inputs, call the tool, and write a tight, specific reading the user actually wants to read. - -## How to use - -1. **Gather inputs.** Ask the user for: - - Birth **date** (required, YYYY-MM-DD). - - Birth **time** (optional, HH:MM 24h). If they don't know, default to noon. - - Birth **timezone** (IANA, e.g. `America/New_York`). If unknown, default to UTC and note the assumption. - - Birth **place** (optional free text — echoed back, not used in calculation). - If the user already gave you any of these in their message, don't re-ask. - -2. **Call `astrology_birth_summary`** with those inputs. The tool returns: - ```json - { - "birth": { "date_utc": "...", "lat": 0, "lon": 0, "place": "" }, - "sun": { "sign": "Leo", "symbol": "♌", "element": "Fire", "modality": "Fixed", "polarity": "Masculine", "ruler": "Sun" }, - "notes": ["..."] - } - ``` - -3. **Read `reference.md`** in this skill folder for the interpretation table. Pull the entries for the user's element + modality + sign + ruler. Keep what's relevant; drop what's noise. - -4. **Compose the reading.** Format: - - One-paragraph **opening** that names the sun sign and what it suggests about the user's core temperament. - - **Element & modality** — what the combination tends to look like in practice. - - **Ruler** — the planet that signals their drive / appetite. - - One **practical observation** the user can take away (a tendency to leverage, or a trap to watch). - Keep the whole reading under ~200 words unless the user asks for depth. - -5. **Acknowledge what's missing.** If the tool's `notes` array mentions that moon / ascendant / houses aren't computed, say so briefly in one line — don't pretend you have them. - -## Constraints - -- Don't invent placements. If the user asks for moon sign or rising sign, tell them this skill computes only the sun and direct them to a service that supports the full chart, OR ask them to provide the moon sign and use the reference table to interpret it. -- Don't be mystical for the sake of it. Readings should sound like a thoughtful friend who happens to know the symbol set, not a horoscope app. diff --git a/skills/astrology/reference.md b/skills/astrology/reference.md deleted file mode 100644 index 31cab72..0000000 --- a/skills/astrology/reference.md +++ /dev/null @@ -1,61 +0,0 @@ -# Astrology reference - -Compact interpretation tables. Pull only the rows you need for a given reading. - -## Sun signs - -| Sign | Symbol | Dates (approx.) | Core themes | -|---|---|---|---| -| Aries | ♈ | Mar 21 – Apr 19 | Initiative, impatience, willingness to start before being ready. | -| Taurus | ♉ | Apr 20 – May 20 | Steadiness, sensual pleasure, stubbornness as a feature. | -| Gemini | ♊ | May 21 – Jun 20 | Curiosity, breadth over depth, talking-as-thinking. | -| Cancer | ♋ | Jun 21 – Jul 22 | Care, memory, protectiveness, mood as information. | -| Leo | ♌ | Jul 23 – Aug 22 | Warmth, performance, the desire to be seen and to give generously. | -| Virgo | ♍ | Aug 23 – Sep 22 | Craft, precision, an eye for what's missing or off. | -| Libra | ♎ | Sep 23 – Oct 22 | Pairing, aesthetics, the diplomat's instinct for balance. | -| Scorpio | ♏ | Oct 23 – Nov 21 | Intensity, depth, the appetite for what's hidden. | -| Sagittarius | ♐ | Nov 22 – Dec 21 | Range, optimism, the urge to keep horizons moving. | -| Capricorn | ♑ | Dec 22 – Jan 19 | Ambition, time-discipline, comfort with structure. | -| Aquarius | ♒ | Jan 20 – Feb 18 | Systems-thinking, group identity, the contrarian's clarity. | -| Pisces | ♓ | Feb 19 – Mar 20 | Imagination, empathy, dissolution of self/other boundaries. | - -## Elements - -| Element | Signs | Tendency | -|---|---|---| -| Fire | Aries, Leo, Sagittarius | Energy outward; momentum, enthusiasm, occasional overshoot. | -| Earth | Taurus, Virgo, Capricorn | Grounded, practical, slow to start but durable once moving. | -| Air | Gemini, Libra, Aquarius | Mental, social, abstracts well; can over-rely on talking. | -| Water | Cancer, Scorpio, Pisces | Feeling-led, attuned to undercurrents; needs solitude to reset. | - -## Modalities - -| Modality | Signs | Tendency | -|---|---|---| -| Cardinal | Aries, Cancer, Libra, Capricorn | Initiates; sets the agenda. | -| Fixed | Taurus, Leo, Scorpio, Aquarius | Sustains; doesn't move easily once set. | -| Mutable | Gemini, Virgo, Sagittarius, Pisces | Adapts; absorbs and re-emits. | - -## Rulers (traditional + modern) - -| Planet | Rules | Drive it signals | -|---|---|---| -| Sun | Leo | Self-expression, vitality. | -| Moon | Cancer | Emotional rhythm, what one needs to feel safe. | -| Mercury | Gemini, Virgo | Communication, processing style. | -| Venus | Taurus, Libra | Pleasure, taste, what one is drawn to. | -| Mars | Aries (and trad. Scorpio) | Drive, anger, sexual appetite. | -| Jupiter | Sagittarius (and trad. Pisces) | Expansion, optimism, philosophy. | -| Saturn | Capricorn (and trad. Aquarius) | Structure, discipline, limits. | -| Uranus | Aquarius (modern) | Disruption, sudden change, originality. | -| Neptune | Pisces (modern) | Dreams, dissolution, idealism. | -| Pluto | Scorpio (modern) | Transformation, power, the underworld. | - -## Compatibility quick notes (sun-to-sun, broad strokes) - -- **Same element**: easy rapport, can lack friction. -- **Adjacent elements (Fire+Air, Earth+Water)**: complementary, often productive. -- **Opposing elements (Fire+Water, Earth+Air)**: more friction, more growth. -- **Same modality, different element**: similar tempo, different fuel — recognise each other. -- **Square (90° apart, e.g. Aries↔Cancer)**: classic challenge pairing. -- **Opposition (180°, e.g. Aries↔Libra)**: mirror — what each lacks the other has. From c85ea0a4084e9ce828ed122e1de97780a3b8826a Mon Sep 17 00:00:00 2001 From: Brendan Playford <34052452+teslashibe@users.noreply.github.com> Date: Sun, 10 May 2026 17:55:47 -0700 Subject: [PATCH 8/9] fix(skills): close audit findings from PR #38 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HIGH: overflow break→continue so ALL overflow skill MCP servers are named in the warning, not just the first - MEDIUM: skill.yaml validation errors (e.g. stdio without command) now returned from UploadDir so SyncDirs surfaces them as errors - MEDIUM: added SetAgentRefreshHook on skills.Service so the provisioner can push MCPServers changes to existing agents - LOW: explicit WARNING log when health check marks a server unhealthy - LOW: RecheckHealth now logs individual scan/update/unhealthy errors instead of silently continuing Co-authored-by: Cursor --- backend/internal/agent/provision.go | 10 ++++-- backend/internal/skills/mcp.go | 4 +++ backend/internal/skills/skills.go | 48 ++++++++++++++++++++++------- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/backend/internal/agent/provision.go b/backend/internal/agent/provision.go index 70ed019..8a50e9a 100644 --- a/backend/internal/agent/provision.go +++ b/backend/internal/agent/provision.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "net/url" + "strings" "sync" "time" @@ -288,10 +289,11 @@ func (p *Provisioner) createAgent(ctx context.Context, userID string) (string, e if err != nil { log.Printf("agent: resolve skill MCP servers: %v (continuing without them)", err) } + var overflow []string for _, e := range entries { if len(mcpServers) >= maxMCPServers { - log.Printf("agent: %d MCP server cap reached, skipping skill %q", maxMCPServers, e.Name) - break + overflow = append(overflow, e.Name) + continue } mcpServers = append(mcpServers, anthropic.BetaManagedAgentsURLMCPServerParams{ Name: e.Name, @@ -305,6 +307,10 @@ func (p *Provisioner) createAgent(ctx context.Context, userID string) (string, e }, }) } + if len(overflow) > 0 { + log.Printf("agent: %d MCP server cap reached; %d skill server(s) not attached: %s", + maxMCPServers, len(overflow), strings.Join(overflow, ", ")) + } } // Skills (instruction bundles) require code-execution because diff --git a/backend/internal/skills/mcp.go b/backend/internal/skills/mcp.go index 29faf50..3e540c4 100644 --- a/backend/internal/skills/mcp.go +++ b/backend/internal/skills/mcp.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/http" "os" "path/filepath" @@ -151,15 +152,18 @@ func (s *Service) RecheckHealth(ctx context.Context) (int, int, error) { for rows.Next() { var id, u string if err := rows.Scan(&id, &u); err != nil { + log.Printf("skills: recheck scan: %v", err) continue } h := healthCheck(ctx, u) if _, err := s.pool.Exec(ctx, `UPDATE skill_mcp_servers SET healthy = $1, updated_at = NOW() WHERE id = $2`, h, id); err != nil { + log.Printf("skills: recheck update %s: %v", id, err) continue } if h { ok++ } else { + log.Printf("skills: recheck: server %s at %s is unhealthy", id, u) fail++ } } diff --git a/backend/internal/skills/skills.go b/backend/internal/skills/skills.go index 2f907c7..c742171 100644 --- a/backend/internal/skills/skills.go +++ b/backend/internal/skills/skills.go @@ -56,8 +56,17 @@ type Skill struct { // Service owns the Anthropic client + Postgres store + filesystem sync. // Construct one in cmd/server/main.go and reuse across handlers. type Service struct { - client anthropic.Client - pool *pgxpool.Pool + client anthropic.Client + pool *pgxpool.Pool + agentRefreshFn func(ctx context.Context) +} + +// SetAgentRefreshHook registers a callback invoked after skill +// upload/delete to push MCP server changes to existing agents. +// Without this hook, only Skills (instruction bundles) are live-updated; +// MCPServers changes require the next user session to re-provision. +func (s *Service) SetAgentRefreshHook(fn func(ctx context.Context)) { + s.agentRefreshFn = fn } func NewService(client anthropic.Client, pool *pgxpool.Pool) *Service { @@ -273,17 +282,26 @@ func (s *Service) UploadDir(ctx context.Context, dir string) (Skill, error) { return Skill{}, fmt.Errorf("persist skill: %w", err) } - // If the skill ships a skill.yaml with mcp_server config, persist - // it so the provisioner can attach the skill's MCP server alongside - // the built-in engagement server. + // If the skill ships a skill.yaml with mcp_server config, validate + // and persist it so the provisioner can attach the skill's MCP + // server alongside the built-in engagement server. Validation + // errors (e.g. stdio without command) are returned so SyncDirs + // surfaces them in its error list rather than silently swallowing. sy, err := readSkillYAML(dir) if err != nil { - log.Printf("skills: warning: %s has invalid skill.yaml: %v (skill uploaded but MCP server not registered)", meta.Name, err) - } else if sy != nil && sy.MCPServer != nil { - if mcpRow, err := s.upsertMCPServer(ctx, sk.ID, sy.MCPServer); err != nil { - log.Printf("skills: warning: %s MCP server registration failed: %v", meta.Name, err) - } else if mcpRow != nil { - log.Printf("skills: %s MCP server registered (transport=%s healthy=%v)", meta.Name, mcpRow.Transport, mcpRow.Healthy) + return Skill{}, fmt.Errorf("skill %s: %w", meta.Name, err) + } + if sy != nil && sy.MCPServer != nil { + mcpRow, err := s.upsertMCPServer(ctx, sk.ID, sy.MCPServer) + if err != nil { + return Skill{}, fmt.Errorf("skill %s MCP server registration: %w", meta.Name, err) + } + if mcpRow != nil { + if mcpRow.Healthy { + log.Printf("skills: %s MCP server registered (transport=%s healthy=true)", meta.Name, mcpRow.Transport) + } else { + log.Printf("skills: WARNING: %s MCP server registered but unhealthy (transport=%s url=%s) — will retry on next sync or RecheckHealth", meta.Name, mcpRow.Transport, mcpRow.URL) + } } } @@ -342,6 +360,14 @@ func (s *Service) refreshAgentsAsync() { if updated+failed > 0 { log.Printf("skills refresh: %d agents updated, %d failed", updated, failed) } + + // Push MCP server changes (MCPServers + Tools) via the + // provisioner hook. The inline loop above only updates Skills + // (instruction bundles); the hook handles the full agent + // config including external MCP servers. + if s.agentRefreshFn != nil { + s.agentRefreshFn(ctx) + } }() } From 5f257cd1ea559f3a93c8b6bae8da9ddcf3728bca Mon Sep 17 00:00:00 2001 From: Brendan Playford <34052452+teslashibe@users.noreply.github.com> Date: Sun, 10 May 2026 20:52:58 -0700 Subject: [PATCH 9/9] fix(agent): don't close SSE stream on intermediate session.status_idle Managed Agents emit session.status_idle between tool-call rounds (while waiting for MCP results) as well as after the agent's final text. The previous code exited on any status_idle after seeing activity, which closed the stream before tool results and the final reading arrived. Track pendingTools: tool_use increments, tool_result decrements. Only forward status_idle as 'done' when pendingTools == 0 and at least one text/tool event was seen this turn. Also add per-event debug logging (agent stream [session]: type=...) to diagnose Managed Agents Beta event ordering issues. Co-authored-by: Cursor --- backend/internal/agent/service.go | 46 +++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/backend/internal/agent/service.go b/backend/internal/agent/service.go index d158822..5ac08d1 100644 --- a/backend/internal/agent/service.go +++ b/backend/internal/agent/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "strings" "github.com/anthropics/anthropic-sdk-go" @@ -82,14 +83,16 @@ func (s *Service) CreateSession(ctx context.Context, teamID, userID, title strin // Run streams agent events for a user message. // -// Ordering matters here: the Anthropic event stream re-emits the most -// recent `session.status_*` event on subscribe, so if we open the stream -// BEFORE sending the user message we'll always receive the stale -// `status_idle` from the previous turn (or from session creation) and -// our consumer will think the run finished before it ever started. Send -// the user message FIRST so the session transitions to running, then -// open the stream and ignore any leading `done` events that arrive -// before we've seen real agent activity. +// Managed Agents emit `session.status_idle` at several points during a +// multi-tool turn: after the user message is queued, between tool-call +// rounds while waiting for results, and once after the agent's final +// text. Only the LAST idle is the real "done". +// +// We track pending tool calls: every `tool_use` / `mcp_tool_use` +// increments, every `tool_result` / `mcp_tool_result` decrements. +// `session.status_idle` is only forwarded as `done` when there are +// zero pending tools AND we've seen at least one text or tool event +// from this turn. func (s *Service) Run(ctx context.Context, sess Session, userText string) (<-chan Event, error) { events := make(chan Event, 32) go func() { @@ -105,20 +108,35 @@ func (s *Service) Run(ctx context.Context, sess Session, userText string) (<-cha defer stream.Close() seenAgentActivity := false + pendingTools := 0 for stream.Next() { - ev := translateStreamEvent(stream.Current()) + raw := stream.Current() + log.Printf("agent stream [%s]: type=%s", sess.AnthropicSessionID, raw.Type) + ev := translateStreamEvent(raw) if ev == nil { continue } - // If the very first event Anthropic re-broadcasts is a stale - // `done`/`error` from a previous turn, drop it and keep - // listening — real agent events for THIS turn are still on - // their way. if !seenAgentActivity && (ev.Type == "done" || ev.Type == "error") { continue } - if ev.Type == "text" || ev.Type == "tool_use" || ev.Type == "tool_result" { + switch ev.Type { + case "text": + seenAgentActivity = true + case "tool_use": + seenAgentActivity = true + pendingTools++ + case "tool_result": seenAgentActivity = true + if pendingTools > 0 { + pendingTools-- + } + case "done": + if pendingTools > 0 { + continue + } + if !seenAgentActivity { + continue + } } emit(events, *ev) if ev.Type == "done" || ev.Type == "error" {