Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ 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
cd backend && go run ./cmd/skills-sync

clean: ## Remove generated artifacts
$(COMPOSE) down -v
rm -rf backend/bin mobile/node_modules mobile/.expo
Expand Down
15 changes: 15 additions & 0 deletions backend/cmd/provision/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -43,6 +57,7 @@ func main() {
Type: anthropic.BetaManagedAgentsAgentToolset20260401ParamsTypeAgentToolset20260401,
},
}},
Skills: skillParams,
})
if err != nil {
log.Fatalf("create agent: %v", err)
Expand Down
26 changes: 23 additions & 3 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -69,10 +70,21 @@ 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,
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,
Expand Down Expand Up @@ -153,6 +165,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())
Expand Down Expand Up @@ -201,7 +218,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)
}

Expand Down Expand Up @@ -274,6 +291,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")
Expand Down Expand Up @@ -321,7 +339,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()
}
Expand Down
67 changes: 67 additions & 0 deletions backend/cmd/skills-sync/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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"
"time"

"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)
}
// 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)
}
}
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
)
75 changes: 66 additions & 9 deletions backend/internal/agent/provision.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +31,12 @@ type UserAgent struct {
// (e.g. /mcp/v1/u/<jwt>) 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.
Expand All @@ -45,6 +52,7 @@ type Provisioner struct {
endpoint MCPEndpointFn
model anthropic.BetaManagedAgentsModel
system string
skillIDs SkillIDsFn

mu sync.Mutex
inflight map[string]*sync.Mutex
Expand All @@ -57,6 +65,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.
Expand All @@ -79,6 +91,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
}
Expand Down Expand Up @@ -224,12 +237,42 @@ 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"

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),
Expand All @@ -241,15 +284,29 @@ 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 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) {
if p.skillIDs == nil {
return nil, nil
}
ids, err := p.skillIDs(ctx)
if err != nil {
return nil, err
}
return skills.SkillParams(ids), nil
}
Loading
Loading