Opens the skill-management-console openspec change (cloud/local skill split with local override) with proposal, design (D1-D11), four delta specs, and tasks. Implements the agent-side persistent local skill store (storage/local_skills.py): authored local skills + cloud-skill overrides in a physically separate SQLite file, with fork-on-revocation. 10 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
15 KiB
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:
- No source. The external Subscription Platform was never built;
SkillSyncRunneris not wired into any running app, sotasks/skills.sqlite3is empty andlist_skills/search_skills/get_skillreturn nothing at runtime. - No management surface. There is no human-facing way to author or manage skills, and no local-authoring path.
- 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 anorigindiscriminator. - 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
SkillSyncRunnerinto 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-toolsD5).
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_idis a local skill → edits it in the local store.skill_idis 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_idis a local skill → deletes it.skill_idis a cloud skill with an active override → removes the override (the cloud skill resurfaces at its next read).skill_idis 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:
- If a local override exists for the id → return the override payload,
origin="cloud",locally_overridden=true. - Else if the synced store has the id (and it is visible) → return it,
origin="cloud". - Else if the local store has the id as a standalone local skill → return it,
origin="local". - Else → not found (no existence leak between stores).
- If a local override exists for the id → return the override payload,
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 asorigin="local".
Migration Plan
Additive across existing packages; no new top-level package.
- Cloud: new Alembic migration
0010_skill_management.pyaddscloud_skills,cloud_skill_entitlements, and a per-hostcloud_skill_sync_state(host_id, last_version, updated_at) table. No existing table is altered. New admin scopeskills:admin(mirrorsllm-providers:admin). - Agent: new
tasks/local_skills.sqlite3created lazily bystorage/local_skills.py.storage/skill_catalog.pyis untouched.api/skill_catalog_view.py+ extendedapi/skill_catalog_mcp.pyadd the merge + authoring surface.api/skill_sync.py's concrete client is repointed at the Cloud API;SkillSyncRunneris 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 newstorage/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_versionmust 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.sourcefor 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_templatetime 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.