## Context `skill-catalog-subscription` (archived) shipped the **consumption** side of Skills: a read-only synced catalog (`storage/skill_catalog.py`), MCP discovery tools (`api/skill_catalog_mcp.py`), and a transport-replaceable sync client (`api/skill_sync.py`) that assumed an external "Subscription Platform" as the skill source. Three gaps remain: 1. **No source.** The external Subscription Platform was never built; `SkillSyncRunner` is not wired into any running app, so `tasks/skills.sqlite3` is empty and `list_skills`/`search_skills`/`get_skill` return nothing at runtime. 2. **No management surface.** There is no human-facing way to author or manage skills, and no local-authoring path. 3. **No local agency.** An agent cannot create skills for itself or adapt a cloud skill to its own context. This change closes all three by introducing a **cloud-side management surface** (this project's Cloud API becomes the skill source, replacing the assumed external platform) and a **local-authoring + override** path on the agent. Skills split into two pools — **cloud-origin** (human-managed via Cloud Console) and **local** (agent-managed via MCP tools) — that never write into each other's store, with a local **override** layer that lets an agent shadow a cloud skill. This builds on `apex-agent-mvp` (MCP server / agent runtime), `database-llm-provider-management` (the Cloud DB + admin-surface pattern mirrored here), and the archived `skill-catalog-subscription` / `skill-authoring` capabilities. ## Goals / Non-Goals **Goals:** - Cloud Console + Cloud API for humans to author/manage cloud-origin skills and assign them to hosts. - Agent MCP authoring tools for the LLM to create/edit/delete its own local skills, and to override a cloud skill locally. - A unified read surface (`list_skills`/`search_skills`/`get_skill`) that merges cloud-synced and local skills with an `origin` discriminator. - Local override of cloud skills (shadow semantics), with a defined lifecycle against sync updates and entitlement revocation. - Make the Skill capability usable end-to-end without an external platform: wire `SkillSyncRunner` into the running host agent, repointing sync at this project's Cloud API. - Per-host visibility of an agent's local skills in the Console (read-only inventory). **Non-Goals:** - The external Subscription Platform (its billing/marketplace/authoring workflow). This project's Cloud API is the source instead. - LLM-driven automatic skill generation. - A human UI for authoring an agent's *local* skills (local authoring is LLM-via-MCP only). - Push/webhook-triggered sync (deferred; pull is baseline — see D3). - Batch/server-side flow execution (unchanged from `skill-mcp-tools` D5). ## Decisions ### D1: Cloud skill store reuses the Cloud platform DB (SQLAlchemy + Alembic) Cloud-origin skills are administrator-managed configuration/content data, directly analogous to LLM Provider profiles. They live in the existing Cloud platform database via new SQLAlchemy models + an Alembic migration, reusing the established repository / admin-scope / CSRF / audit machinery (mirroring `cloud/llm_providers.py` + migration `0007`). No separate store. ### D2: Per-host entitlement A cloud skill is visible to an agent only if it is explicitly entitled to that agent's host. New cloud tables: `cloud_skills` (the skill rows) and `cloud_skill_entitlements` (a `skill_id × host_id` mapping). The Console manages both skill CRUD and entitlement assignment. An agent syncs only its own host's entitled set. There is no global visibility and no tenant/group abstraction in this change. ### D3: Incremental pull-only sync The agent periodically pulls an incremental delta of its host's entitled skills from the Cloud API. The Cloud maintains a monotonic **per-host `entitlement_version`** that bumps on any change to that host's entitled set (skill added/removed/edited, entitlement granted/revoked). The agent sends `since_version`; the Cloud returns upserted skills, `removed_ids`, and the new `latest_version`. Full-replace is used on first sync (`since_version` absent) or when the Cloud cannot serve an incremental delta (version too old). The existing `SubscriptionClient` Protocol / `SyncDelta` shape already supports this; only the concrete HTTP client and endpoint change. **Push/webhook is deferred** — correctness never depends on it. ### D4: Local skills persist to a dedicated SQLite file The agent's local skills (authored + overrides) persist in a new file `tasks/local_skills.sqlite3`, physically separate from the synced `tasks/skills.sqlite3` and from `tasks/tasks.sqlite3`. This preserves the archived "no cross-writes / physical separation" boundary from `skill-authoring` / `skill-catalog-subscription` D6-D7. The existing in-memory `skills_learning.SkillStore` (used by local-synthesis/embedding subsystems) is **not** the home for managed local skills; a new persistent local-skill store is introduced (see Layering). The existing `Skill`/`FlowTemplateSkill`/`KnowledgeSkill` domain models are reused verbatim; the `SkillMetadata.source` field discriminates local skills (`source = "local"`) from synced ones. ### D5: Origin discriminator is a simple read-surface field The merged read surface reports each skill with `origin: "cloud" | "local"`, derived from which store it came from (not stored redundantly). No entitlement/billing/cloud-management internals are exposed to the LLM. Overrides add a `locally_overridden: true` flag (see D11). This is the only provenance information the LLM receives. ### D6: No authoring guard The authoring MCP tools (`create_skill`, `update_skill`, `delete_skill`) are always registered on the agent's MCP server; there is no enable/disable config gate. The blast radius is already limited to the agent's own local store (cloud skills cannot be mutated directly — see D10), so an operator switch is not warranted for this change. ### D7: Agent reports its local skill inventory up to the Cloud (read-only) The agent periodically reports a read-only summary of its local skills (authored + active overrides) to the Cloud API, so the Console can display per-host local skills. This is a new agent→Cloud reverse channel (a host-scoped `POST` carrying local skill metadata, no secrets/content beyond what is already non-sensitive). The Console renders it per host. Reporting is best-effort: a failed report does not affect local operation. ### D8: Override always shadows A local override for a cloud skill id takes precedence at read time for as long as it exists. When the underlying cloud skill updates via sync, the override **continues to shadow** the new cloud version — the agent does not see the cloud update until the override is removed. This is simple and predictable; staleness is the operator/agent's responsibility. (Version-staleness flagging and auto-invalidation were considered and rejected for MVP complexity.) ### D9: Override forks into a standalone local skill on entitlement revocation If a cloud skill is removed from the agent's entitled set (sync delivers it in `removed_ids`) **and** a local override exists for it, the override is **promoted to a standalone local skill**: it gets a local id, `origin` becomes `"local"`, and it survives independently of the (now-absent) cloud skill. The agent keeps its customized version rather than losing it. If no override exists, revocation simply removes the cloud skill from the local view as before. ### D10: Authoring tools dispatch by origin (no dedicated override tool) - `create_skill(...)` → creates a new **local** skill (fresh local id). - `update_skill(skill_id, ...)`: - `skill_id` is a **local** skill → edits it in the local store. - `skill_id` is a **cloud** skill → creates/updates the local **override** for that cloud id (never writes to the cloud store or the synced store). - `delete_skill(skill_id)`: - `skill_id` is a **local** skill → deletes it. - `skill_id` is a **cloud** skill with an active override → removes the override (the cloud skill resurfaces at its next read). - `skill_id` is a **cloud** skill with no override → semantic error (nothing local to delete; the agent cannot revoke a cloud entitlement from here). Dispatch is by origin lookup, not by an id-prefix convention. A dedicated `override_skill` tool was considered and rejected in favour of fewer tools with origin-based dispatch. ### D11: Origin reporting for overrides - Cloud skill, no override: `origin = "cloud"`, no flag. - Cloud skill with an active override: `origin = "cloud"`, `locally_overridden = true` (provenance is still cloud; the flag says it is shadowed). - After a revocation fork (D9): `origin = "local"` (it has detached from the cloud identity). - Authored local skill: `origin = "local"`. ## Architecture / Layering Per `docs/CONSTITUTION.md` (HTTP/MCP enter at `api`; no LLM/HTTP/MCP in `core`/`driver`/`device`/`tools`; dependency direction `core → driver/device → tools → perception → storage → runtime → api`): | Concern | Location (new/extended) | Layer | Key deps | |---|---|---|---| | Cloud skill domain + service | `packages/cloud-platform/cloud/skills.py` (new) | cloud service | dataclasses, validation, `cloud.provider_secrets` only if secrets ever needed (none here) | | Cloud SQLAlchemy models | `packages/cloud-platform/cloud/db_models.py` (extend) | cloud storage | SQLAlchemy | | Cloud repository methods | `packages/cloud-platform/cloud/sql_repository.py` (extend) | cloud storage | SQLAlchemy, `cloud.skills` | | Cloud Alembic migration | `cloud/migrations/versions/0010_skill_management.py` (new) | cloud storage | Alembic | | Cloud admin REST (skill CRUD + entitlement) | `cloud/internal_api/api.py` + new admin router (extend) | cloud api | FastAPI, `cloud.skills`, auth/CSRF/scope | | Cloud host-scoped sync endpoint + inventory-report endpoint | `cloud/internal_api/api.py` (extend) | cloud api | FastAPI, host-scoped bearer auth (same as planner-decision) | | Persistent local-skill store (authored + overrides) | `storage/local_skills.py` (new) | storage | `sqlite3`, `skills_learning.models` — zero HTTP/MCP | | Unified read/merge + origin + override resolution | `api/skill_catalog_view.py` (new) | api | `storage.skill_catalog`, `storage.local_skills` | | Skill MCP tools (read merge + authoring dispatch) | `api/skill_catalog_mcp.py` (extend) | api | `api.skill_catalog_view`, FastMCP | | Sync concrete client repoint + runner wiring | `api/skill_sync.py` (extend) + host-agent app bootstrap | api / runtime | `httpx`, `storage.skill_catalog` | | Console view + API client + types | `cloud-console/src/views/SkillsView.vue`, `cloud-console/src/api`-equiv, `cloud-console/src/types.ts` (extend) | frontend | Vue, existing admin-auth/CSRF client | `storage/skill_catalog.py` (the synced store) is **unchanged in its read-only-except-sync contract**; it continues to receive only sync writes. Local authored skills and overrides live exclusively in `storage/local_skills.py` → `tasks/local_skills.sqlite3`. The merge happens in `api/skill_catalog_view.py`, the only module that reads both. ## Override Model (detail) - Overrides are stored in the local store keyed by the cloud skill id they shadow. - Read merge order, per id: 1. If a local override exists for the id → return the override payload, `origin="cloud"`, `locally_overridden=true`. 2. Else if the synced store has the id (and it is visible) → return it, `origin="cloud"`. 3. Else if the local store has the id as a standalone local skill → return it, `origin="local"`. 4. Else → not found (no existence leak between stores). - `update_skill(cloud_id)` upserts the override; `delete_skill(cloud_id)` removes it (cloud skill resurfaces on next read). - Fork-on-revocation (D9): when sync removes a cloud id that has an override, the override row is rewritten to a standalone local skill (new local id, `source="local"`, content preserved), and the old cloud-id key is dropped. Subsequent reads see it as `origin="local"`. ## Migration Plan Additive across existing packages; no new top-level package. - **Cloud**: new Alembic migration `0010_skill_management.py` adds `cloud_skills`, `cloud_skill_entitlements`, and a per-host `cloud_skill_sync_state` (host_id, last_version, updated_at) table. No existing table is altered. New admin scope `skills:admin` (mirrors `llm-providers:admin`). - **Agent**: new `tasks/local_skills.sqlite3` created lazily by `storage/local_skills.py`. `storage/skill_catalog.py` is untouched. `api/skill_catalog_view.py` + extended `api/skill_catalog_mcp.py` add the merge + authoring surface. `api/skill_sync.py`'s concrete client is repointed at the Cloud API; `SkillSyncRunner` is constructed and started in the host-agent app bootstrap (behind the existing cloud-transport configuration). - **Rollback**: drop the cloud migration's tables; delete `tasks/local_skills.sqlite3`; remove the new `storage/local_skills.py`, `api/skill_catalog_view.py`, the authoring tool registrations, and the Console view. The synced store and original read tools revert to their archived behaviour (empty until an external source exists). ## Risks / Trade-offs - **[Risk]** Sync staleness under overrides (D8): an override masks cloud updates indefinitely. → **Mitigation**: explicit, documented; the Console's per-host inventory (D7) surfaces active overrides so an operator can see what is shadowed. - **[Risk]** Fork-on-revocation (D9) can let an agent keep a customized copy of a skill the operator tried to revoke. → **Mitigation**: the fork contains only what the agent already had (no new content); revocation still removes the cloud entitlement. Accepted trade-off: local agency over strict central control. The Console inventory makes forks visible. - **[Risk]** Per-host entitlement + incremental versioning adds cloud-side bookkeeping (the `entitlement_version` must bump correctly on every relevant change). → **Mitigation**: centralize the bump in the service layer (`cloud/skills.py`) behind the repository port; cover with repository/service tests. - **[Trade-off]** No authoring guard (D6) means an agent can fill its local store freely. Accepted: blast radius is local-only and the store is per-agent. - **[Trade-off]** Reusing `SkillMetadata.source` for origin keeps the domain model unchanged but means origin is inferred at the merge layer rather than stored as a first-class field. Accepted: avoids model divergence between synced and local skills. - **[Risk]** Inventory reporting (D7) is a new agent→Cloud channel that could leak local-skill metadata upstream. → **Mitigation**: report metadata only (id/name/kind/origin/overridden-cloud-id/version/timestamps), never secrets; the agent already trusts the Cloud API as its sync source. ## Open Questions - Exact Cloud sync endpoint path and whether inventory report rides on the same host-scoped auth as the planner-decision endpoint (likely yes). - Whether flow-template tool-reference validation (already applied to synced skills) should also apply to locally-authored/override skills at author time vs read time (lean: validate at `get_skill`/`resolve_flow_template` time uniformly, warn — not block — at author time). - Console UX for entitlement assignment (per-skill host picker vs per-host skill picker) — a UI detail, resolved during Console implementation.