Files
agentic-mobile-control/openspec/changes/skill-management-console/design.md
T
q792602257andClaude Opus 4.6 dd03abbbb0 feat(skills): open skill-management-console change + local skill store
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>
2026-07-15 07:28:42 +08:00

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:

  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.pytasks/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.