// Package skills implements the agentic skills platform: user-creatable // agent definitions (system prompt + tool whitelist + I/O spec) that run // in-process via majordomo's agent loop. // // A Skill is a saved agent definition. It can be invoked from Discord // (.skill ), exposed to the chatbot as a tool (via the // SkillsToolProvider), and (in v2) scheduled. Skills compose tools from // the skilltools registry, gated by a three-stage permission model: // save-time AuthoringRequirement, share-time SafeForShare, execute-time // SkillNameGate. // // This file declares the domain types only. Storage lives in storage.go; // validation lives in validate.go. The grand storage pattern documented in // pkg/logic/storage/CLAUDE.md applies — when adding a field to Skill, you // MUST also update pkg/logic/skills/gorm_model.go (gormSkill, fromStorage, // toStorage) or persistence will silently break. package skill import "time" // Skill is the domain definition of an agentic skill. // // Why: a skill is a saved agent definition reusable across invocations // (Discord, chatbot tool, scheduled run in v2). The struct is intentionally // flat — every field lives on its own column on the skills table; there is // no JSON-blob spec column. This keeps queries (e.g. "list all skills with // chatbot exposure") indexable and avoids opaque migration headaches. // // What: identity + authoring + agent spec + visibility + chatbot exposure // fields, all on one struct. // // Test: see validate_test.go and integration_test.go for round-trip and // validation coverage. type Skill struct { // Identity ID string // UUID OwnerID string // Discord member ID; empty for builtin Name string // unique per (owner, builtin namespace) Description string Source Source // SourceBuiltin | SourceManual CreatedAt time.Time UpdatedAt time.Time // Authoring (copied at save time from the user) AuthoredBy string // member ID at time of last edit (audit; may differ from owner over time) // Versioning (for builtins; user skills typically stay at 1.0.0) Version string // semver; used by builtin loader to decide re-seed // Spec — agent definition SystemPrompt string Tools []string // registry tool names ModelTier string // "fast" | "standard" | "thinking" | explicit "provider/model" InputSchema []InputParam OutputTarget OutputTarget Schedule string // cron; empty = on-demand only; rejected in v1 (ships in v2) Visibility Visibility // VisibilityPrivate | VisibilityShared | VisibilityPublic SharedWith []string // member IDs for visibility=shared MaxIterations int // 0 → use convar default MaxToolCalls int // 0 → use convar default MaxRuntime time.Duration // 0 → use convar default InitialMessage string // Chatbot exposure (v1 — proves out the platform via mortventure) ExposeAsChatbotTool bool ChatbotToolName string ChatbotToolDescription string ChatbotChannelFilter string // named filter from the channel-filter registry // Admin gating (v2 — public scheduled channel skills require approval). // DEPRECATED in v3: PinnedVersionID subsumes this flag for non-owner // invocation gating. CanInvoke no longer references this column. // Drop in v4. PendingApproval bool // Pinned version (v3 — admin-curated invocation gate). // // Why: in v3, non-owner invocation requires that an admin explicitly // pin a known snapshot. This replaces v2's PendingApproval flag — // pinning is the explicit "approved for general use" signal, and the // pinned snapshot is what executes for non-owner callers (so an owner // editing a public skill never accidentally exposes work-in-progress // to other users). // // PinnedVersionID is the SkillVersion.ID (UUID) of the snapshot that // non-owner invocations resolve to. Empty means "no pin yet" — only // the owner and admins can invoke. // // Schema column is `pinned_version` per the design spec but the field // name in the domain struct is explicit about the kind of value it // holds (a snapshot row's UUID, NOT a semver string), which avoids // the spec ambiguity around "pin to v1.0.5" potentially mapping to // multiple snapshot rows over time. PinnedVersionID string // PinnedAt is the wall-clock time the pin was set. Zero means // PinnedVersionID is empty (never pinned). PinnedAt time.Time // PinnedBy is the admin member ID who set the current pin. Empty // when PinnedVersionID is empty. PinnedBy string // Scheduler bookkeeping (v2). Updated by the scheduler runner after // a successful (or failed-but-counted) scheduled execution. // // LastScheduledRunAt records the wall-clock time of the most recent // scheduled invocation; zero means "never run on schedule". // // NextRunAt is the precomputed wake-up time the scheduler polls for // (`WHERE next_run_at <= NOW()`). It is recomputed by feeding // LastScheduledRunAt (or NOW() on first scheduling) through // ParseSchedule(Schedule).Next(...). Manual / on-demand invocations // MUST NOT touch these fields. LastScheduledRunAt time.Time NextRunAt time.Time // ExtendedBounds, when true, lets a non-admin author save the skill // with bounds (MaxIterations / MaxToolCalls / MaxRuntime) above the // default tier (12/30/60s) up to the extended tier (50/150/600s). // Set by an admin via `.skill admin grant-extended `. Cleared // by `.skill admin revoke-extended `. Builtins and admin- // authored skills bypass the cap entirely (the tier resolution in // Validate treats AuthorIsAdmin and ExtendedBounds equivalently). // // Why a per-skill flag vs a per-user grant: governance is per-skill // — an admin reviews a specific skill's bounds and decides those // resource limits are justified for THAT skill. A user grant would // blanket-allow expensive bounds on every skill they author. ExtendedBounds bool // ParallelCompositionAllowed gates whether this skill may use the // skill_invoke_parallel tool. Default false. // // Why a per-skill admin gate: parallel fan-out multiplies blast // radius (one bad skill spawns N concurrent runs). Admins approve // each skill that's allowed to use parallel composition; granting // is per-skill via `.skill admin grant-parallel `. Builtins // may set this directly in skill.yml (the loader bypasses // save-time gates by design). // // Checked AT INVOCATION TIME (every skill_invoke_parallel call), so // admins can grant or revoke without redeploying. The check lives // in the tool handler (pkg/skilltools/tools/skill_invoke_parallel.go) // via the SkillInvokerProvider.IsParallelAllowed extension. ParallelCompositionAllowed bool // ExecutionLane is the named lane the skill's runs are submitted to // when the executor routes through pkg/lane (v6). Default // "skill-default"; admin overrides per-skill via // `.skill admin set-lane `. // // Why per-skill (vs a single global skill lane): different skills // have different concurrency profiles. A long-running web-research // skill might warrant a dedicated 1-slot lane to avoid starving // quick chatbot-exposed skills; an admin should be able to isolate // it without a code change. // // Empty string falls through to "skill-default" at executor time // — keeping the field nullable lets a future schema change // distinguish "explicit skill-default" from "never set". ExecutionLane string // WebhookSecret enables inbound webhooks (v7). Empty = disabled // (the default). Non-empty = the random secret URL path segment // for POST /webhooks/. Generated by EnableWebhook; // rotated by RegenerateWebhookSecret. Storage is varchar(64) and // the secret is 32 random bytes (64 hex chars), so the column // holds a fully unique secret per skill. // // Why store the secret directly (not a hash): the webhook handler // must look up the skill by the secret on every POST, which would // require comparing every stored hash against the supplied secret // — a per-call O(n_skills) operation. The secret is treated as a // long random URL key (like a paste UUID); compromise is mitigated // via RegenerateWebhookSecret rotation, not via storage hashing. WebhookSecret string // WebhookSignatureRequired controls whether the inbound webhook // handler verifies HMAC against the X-Mort-Signature header. Default // true (the storage column default). Toggling to false skips HMAC // verification — useful for low-stakes integrations behind an IP // allowlist where the caller can't easily compute HMAC. Owners // flip this on the management page; admins can also force it // back on if a leaked allowlist becomes a concern. WebhookSignatureRequired bool // WebhookIPAllowlist is a newline-separated list of CIDR blocks // (or bare IPs). Empty string = no allowlist (accept any source // IP). The handler parses the list at request time so updates take // effect immediately without a redeploy. Invalid CIDR entries // are silently dropped at parse time (the management page form // shows a parse-error preview before save). WebhookIPAllowlist string // EncryptionEnabled (v8) opts the skill into per-skill envelope // encryption for KV values and file blob content. Default false // (plaintext storage; matches the legacy default). When true, new // writes go through the AES-256-GCM helpers in pkg/skilltools and // the corresponding skill_kv / skill_file_blobs row stamps // encryption_key_version=1; reads transparently decrypt rows whose // version > 0 and pass through rows whose version == 0 (mixed // storage is supported indefinitely). // // !!!!! OPERATIONAL WARNING !!!!! This flag is a write-side switch // only. Disabling encryption for an already-encrypted skill does // NOT decrypt existing rows — they remain reachable as long as // the master key is intact. Losing SKILLS_ENCRYPTION_MASTER_KEY // renders every encrypted row unreadable; back the master key up // separately from database backups. See pkg/skilltools/encryption.go // for the full operational rules. EncryptionEnabled bool // Preemptible (v9) opts the skill into preemption: when a higher- // priority job arrives at a full lane, this skill's running job may // be cancelled mid-flight to free a slot. Default false. // // !!!!! OPERATIONAL WARNING !!!!! Preemption means the skill's // scaddy.Agent context is cancelled mid-step; any partial side // effects (file writes, KV updates, sent emails, etc.) remain // committed. Only mark a skill preemptible when it is idempotent // or read-only — otherwise the user-visible state may be // inconsistent with the run's "preempted" terminal status. // // The lane scheduler will not preempt jobs younger than // `skills.lane.preemption_min_runtime_seconds` (default 30s) to // prevent thrashing. The preempted run is recorded with // status="preempted". Preemptible bool // DefaultPriority (v9) is the per-skill default priority used by // the lane scheduler's fair-share queue ordering. Higher numbers // run first within a single user's sub-queue. Default 0. // // Per-invocation overrides (skill_invoke priority arg, webhook // X-Mort-Priority header) win over this default. Owners may set // values in the range [-`skills.priority_max_per_user`, // +`skills.priority_max_per_user`] (default cap 5); admins may // exceed the cap. DefaultPriority int // Tags is a free-form set of short labels owners attach to a skill // for organisation + discovery. The list page renders each tag as a // chip and offers a dropdown filter populated from all visible // skills' tags. // // Why a separate field (vs reusing Description / Tools): tags are a // curatorial signal, not part of the agent spec — they only matter // to humans browsing the list. Storing them on the skill row (vs a // side table) keeps lookups index-only and matches how the rest of // the skill's flat fields are persisted. // // Validate enforces: each tag is trimmed + lowercased; max 32 chars // per tag; max 16 tags per skill; duplicates within a single skill // are deduped. Tags []string // DeprecatedByAgentID is the Phase 7 soft-retire pointer: when // non-empty, the Skill is "soft retired" — hidden from default // listings (`.skill list`, the webui index, chatbot tool exposure) // but STILL invokable via `.skill ` and via `skill_invoke` // tool calls. The string is the agents.Agent.ID of the replacement // Agent that supersedes this Skill. // // Why a pointer (not a bool): a future audit / migration tool needs // to follow the soft-retire link back to the replacement. An admin // browsing the deprecated-skills page wants to see "what should I // use instead?" without a separate lookup table. // // Why keep the Skill row (not drop it): existing skill_invoke calls // in user-authored skills, scheduled jobs, and webhook integrations // would break if the row vanished. Soft-retire preserves the // callable surface while signalling "this is the old name; the // replacement Agent is the curated version." // // Set by the Phase 7 boot migration (pkg/logic/agents/migrate_phase7.go); // admins may also flip it manually via storage tooling. Listing // methods filter on this field by default but explicit GetByName / // GetForInvocation lookups bypass the filter so direct invocation // continues to work. DeprecatedByAgentID string // DefaultEmoji is an optional identity emoji for the skill, shown // as the __start__ fallback when StateReactEmoji has no __start__ // entry. Also forwarded to the invoking Discord message when a // parent agent calls this skill via skill_invoke, so the user sees // the child skill's identity emoji during execution. DefaultEmoji string // StateReactEmoji maps tool names (and reserved keys "__start__", // "__end__", "__error__") to Discord emoji that the bot reacts to // the invoking message with as the skill progresses. Empty map // (the default) disables state-react reactions for this skill. // // Why: the legacy `.query` agent surfaced live progress via emoji // reactions on the invoking message (magnifying glass on search, // page on read, …). Skills inherit the same UX without each // author having to wire `update_status` for trivial signalling — // the emoji map is declarative and the executor calls inv.OnEvent // at the relevant boundaries. update_status remains for richer // interim text; emoji reactions are an additive lightweight signal. // // Reserved keys: // - __start__: reacted right before agent.Run starts // - __end__: reacted on successful completion // - __error__: reacted on terminal error // // Tool keys: react fires on each tool dispatch. Repeated reactions // of the same emoji are no-ops at Discord (idempotent), so a skill // that calls web_search 5x just leaves one 🔍. // // Map values are arbitrary Discord emoji strings (unicode emoji, // custom emoji `<:name:id>`, animated ``). Validate does // not enforce a format — Discord rejects invalid emoji at react // time and the executor swallows that with a log line. StateReactEmoji map[string]string } // ThreadIDInputKey is the magic key under skilltools.Invocation.SkillInputs // that the v2 .skill new / .skill edit wizard handlers use to thread a // pre-created thread channel ID through to delivery. When // OutputTarget.Kind == "thread" and this key is present in // inv.SkillInputs, delivery posts directly to that thread channel; // otherwise it falls back to OutputTarget.Target / inv.ChannelID. // // Why a magic input key vs an OutputTarget override field: keeps the // wire shape (Skill struct) unchanged and keeps the override scoped // to a single invocation. Wizard commands set this immediately after // MessageThreadStartComplex; nothing else writes it. // // Why defined here vs in skillexec: wizard command handlers in this // package need to write the key, and skillexec imports skills (so // the reverse import would cycle). Skillexec aliases this constant. const ThreadIDInputKey = "__thread_id__" // Source distinguishes builtins (loaded from skills//skill.yml on // boot) from user-authored manual skills. // // Why: builtin skills bypass save-time authoring and share-time safety // checks because the loader is trusted infrastructure. type Source string const ( SourceBuiltin Source = "builtin" SourceManual Source = "manual" ) // InputParam declares a typed input slot on a skill, populated at // invocation time from positional/flag args (Discord) or form fields // (webui). // // Why: skills are invoked from heterogeneous surfaces and need a uniform // schema for input collection and validation. The Type drives string→typed // coercion in skillexec.validateInputs; Choices restricts to an enum set. type InputParam struct { Name string Description string Type string // "string"|"int"|"float"|"bool"|"user"|"channel"|"url" Required bool Default string // string-encoded; parsed per Type at invocation Choices []string } // OutputTarget controls where the executor delivers a skill's output. // // Why: skills run in many contexts and the user shouldn't have to think // about delivery — the spec encodes it once. The Discord delivery // implementation in pkg/logic/skillexec/delivery.go reads this struct. type OutputTarget struct { Kind string // "channel"|"dm"|"thread"|"webui_only"|"channel_with_summary" Target string // channel/member/thread ID, or empty for caller-context } // Visibility controls who may invoke a skill. // // Why: separates *invocation* gating (this struct) from *tool authoring* // gating (skilltools.Permission) — they are orthogonal. A non-admin can // invoke an admin-authored public skill that uses db_select; the permission // model for the underlying tool only fires at save time, not invocation. type Visibility string const ( VisibilityPrivate Visibility = "private" VisibilityShared Visibility = "shared" VisibilityPublic Visibility = "public" ) // IsKnownVisibility reports whether v is a recognised visibility value. // Used by Validate. func IsKnownVisibility(v Visibility) bool { switch v { case VisibilityPrivate, VisibilityShared, VisibilityPublic: return true } return false } // IsKnownOutputKind reports whether kind is a recognised OutputTarget.Kind. // Used by Validate and by the Discord delivery switch. // // "channel_with_summary" is the v-research delivery kind: full output // posts to a configured spam channel (skills.research.spam_channel_id) // while a generated summary posts in the original channel as a reply // linking back. Falls through to plain "channel" behaviour when the // spam channel convar is unset or matches the invocation channel. // Validate accepts this kind here; the Discord delivery switch in // pkg/logic/skillexec/delivery_discord.go is the consumer side. func IsKnownOutputKind(kind string) bool { switch kind { case "channel", "dm", "thread", "webui_only", "channel_with_summary": return true } return false } // IsKnownInputType reports whether t is a recognised InputParam.Type. // Used by Validate and by skillexec.validateInputs for coercion dispatch. func IsKnownInputType(t string) bool { switch t { case "string", "int", "float", "bool", "user", "channel", "url": return true } return false }