Skip to content

Data Model — 005 Skill Manager

Entities, fields, relationships, and SQLite additions for the skill manager. Depends on the agent kind from spec 004 and the kind-agnostic Resource framework from spec 001.

Domain entities (backend/coffer/domain/skill/)

SkillSource (domain/skill/source.py)

A Pydantic model recording where a managed skill came from. In v1, only local-folder import is supported.

LocalImportSource

FieldTypeNotes
typeLiteral["local_import"]source type
original_pathstrinformational only; not retained as a live dependency

SkillConfig (domain/skill/config.py)

Pydantic v2 BaseModel.

FieldTypeNotes
sourceLocalImportSourcesingle local_import source
skill_md_namestrSKILL.md frontmatter name; equals Resource.name
skill_md_descriptionstrfrontmatter description
version_hashstrsha256 of SKILL.md content at last sync
last_synced_from_source_atdatetime | NoneUTC; set on import

SkillFrontmatter (domain/skill/frontmatter.py)

Pydantic v2 model used during validation of an imported folder. Aligns with the agentskills.io constraints:

FieldTypeConstraint
namestrrequired, 1–64 chars, ^[a-z0-9][a-z0-9_-]{0,63}$
descriptionstrrequired, 1–1024 chars
licensestr | Noneoptional; recognized, not interpreted
allowed_toolslist[str] | Noneoptional (allowed-tools); normalized from list or delimited str

name accepts a documented superset of the standard's charset — the standard allows lowercase letters, digits, and hyphens, and Coffer also tolerates underscores for backward-compatibility. license and allowed-tools are third-party authored, so recognizing them stays additive: a non-string license scalar is coerced to a string and a malformed allowed-tools value is tolerated (treated as absent) rather than failing validation. Every other unrecognized field is tolerated under extra='allow'.

The frontmatter description is stored on the skill kind's config as SkillConfig.skill_md_description (see above) — this is the authoritative copy. The resources row has its own description column inherited from the kind-agnostic Resource framework; on import it is seeded from the frontmatter description for parity with other kinds, but it is not re-synced afterwards (treat it as a free-form human label after the initial write).

BindingState (domain/skill/binding.py)

Plain dataclass; in-memory representation of one row from skill_agent_bindings.

FieldTypeNotes
skill_resource_idintFK
agent_resource_idintFK
enabledbool
last_linked_atdatetime | Nonelast successful link op
last_link_pathstr | Noneabsolute path where the link was created
link_modeLinkMode | Nonesymlink, junction, or copy_fallback; mirrors SkillBindingOut.link_mode and lets the UI flag degraded bindings

DriftKind (domain/skill/drift.py)

String-valued enum.

ValueMeaningSuggested remedy
missing_linkbinding enabled but no target on diskre-enable to re-link
tampered_linksymlink target is not Coffer's masterdisable + re-enable, or use --force
replaced_with_regularpath is a regular file/dir instead of a linksame as above
missing_masterbinding refers to a master folder that's gonere-import
orphan_mastermaster folder on disk has no DB recordadopt or remove

Unmanaged Skill (domain/skill/scan.py + domain/agent/scan.py) — workspace amendment

A derived (never stored) view of a skill-shaped entry found in an agent's skill locations that Coffer does not manage (FR-022). The filesystem is the source of truth; adoption or deletion are the only mutations.

scan_locations(agent_type, config_dir) lives in domain/agent/scan.py (it depends on AgentType, which domain/skill must not import — Contract 5c) and returns the ordered directories to scan: <config_dir>/skills for both types, plus ~/.agents/skills for codex. The infrastructure layer (infrastructure/skill/workspace_scan.py) walks them into ScanEntry values (name, path, is_dir, link_target), and the pure classify in domain/skill/scan.py turns those into UnmanagedSkill results:

  • entries that are Coffer-managed links (symlink resolving inside ~/.coffer/skills/) are excluded;
  • dot-entries (e.g. Codex's .system) and plain files are silently excluded;
  • symlinks pointing outside the master store are listed with foreign_link=True and are never adoptable;
  • plain directories are listed and adoptable.

Surfaced fields (UnmanagedView in application/skill/unmanaged_ops.py):

FieldTypeNotes
namestrfolder name
pathstrabsolute path on disk
locationstr"skills" (<config_dir>/skills) or "agents_dir" (~/.agents/skills)
validboolpasses AgentSkills validation (FR-004)
reasonstr | Nonevalidation failure reason when invalid
foreign_linkboolsymlink targeting outside the master store — surfaced, never adoptable

Follow Policy (stored on the agent resource's config, spec 004) — workspace amendment

Per-agent skill-delivery policy (FR-025): follow_all_skills: bool (default True, preserving the pre-amendment trust mode) plus skill_exclusions: list[str]. The fields live on AgentConfig (spec 004's schema, updatable via PATCH /agents/{name} / coffer agent follow); this spec owns their delivery semantics. While following, the agent's effective skill set is the entire master store minus its exclusions; bindings remain the persistent delivery record. application/skill/follow_ops.py reconciles deliveries when the flag flips, when a skill is registered or removed, and when the exclusion list changes; disabling the flag preserves the currently delivered skills as explicit per-skill bindings. The policy is read through an injected agent_skill_policy_resolver so skill code never imports agent-kind code (Contract 5c).

SQLite schema additions

Migration 20260526_0005_skill_tables.py (revision 0005, down_revision 0004) adds the skill binding table. Agents themselves live in the shared resources tables, so spec 004 needs no dedicated agent-tables migration.

skill_agent_bindings

ColumnTypeConstraints
skill_resource_idintFK → resources(id) ON DELETE CASCADE
agent_resource_idintFK → resources(id) ON DELETE CASCADE
enabledboolnot null, default 0
last_linked_attimestampnullable
last_link_pathtextnullable
link_modetextnullable; one of symlink, junction, copy_fallback when populated
primary key(skill_resource_id, agent_resource_id)

Index: idx_bindings_agent on (agent_resource_id, enabled) — supports "which skills are enabled for this agent" queries.

Reuse of existing tables

  • resources: new rows with kind='skill'. No schema change.
  • audit_log: new event types written (see below).

Audit event types added

Add to AuditEventType:

ValueWhen emitted
skill_importedLocal-path import succeeds
skill_updatedIn-place file edit changes skill content (with before/after hashes)
skill_boundPer-agent binding enabled (symlink created)
skill_unboundPer-agent binding disabled (symlink removed)
skill_drift_detectedverify op reported drift (count + categories in details)

The workspace amendment adds:

ValueWhen emitted
skill_adoptedAn unmanaged skill folder was adopted into the master store (FR-023)
skill_unmanaged_deletedAn unmanaged skill folder was deleted from an agent's workspace (FR-024)
skill_autobind_skippedAuto-bind / follow reconciliation skipped an agent (e.g. target conflict; best-effort)
skill_relinkedAn enabled binding's managed link was re-created at a new delivery path (e.g. after a config_dir change)

Skill removal has no dedicated event — deleting a skill goes through ResourceService.delete, which emits the generic resource_deleted event (with a pre-delete snapshot in details), the same as any other resource kind.

On-disk layout

~/.coffer/
  skills/
    <skill-name>/           # canonical master, one per skill
      SKILL.md
      scripts/ ...           # optional
      references/ ...        # optional
      assets/ ...            # optional
      .coffer.meta.json      # source provenance redundancy; not authoritative

.coffer.meta.json mirrors a subset of SkillConfig for forensic recovery if the DB is lost. The file is written by MasterStore immediately after the master folder content is copied (i.e. at the end of import) and is rewritten in place on every subsequent successful sync. It is not read by Coffer at runtime; the DB is authoritative and wins on any disagreement.

Keys persisted:

KeySourceNotes
sourceSkillConfig.sourcelocal_import source with original_path
skill_md_nameSkillConfig.skill_md_namematches the master folder name at write time
skill_md_descriptionSkillConfig.skill_md_description
version_hashSkillConfig.version_hashsha256 of SKILL.md at last sync
last_synced_from_source_atSkillConfig.last_synced_from_source_atISO-8601 UTC

Per-agent symlink targets land at:

<config_dir>/skills/<skill-name>  → symlink/junction to  ~/.coffer/skills/<skill-name>

Per-agent delivery targets

Each agent declares how and where Coffer delivers a skill via the capability manifest (domain/agent/descriptor.py): a skill_delivery_mode (SkillDeliveryModefolder / rules_mdc / external_dir) plus, for the folder model, a skill_subpath under the agent's config dir. The skill service reads the mode through a composition-root resolver that returns a plain string (Contract 5: the service never imports the descriptor).

AgentDelivery modeFolder targetStatus
Claude Codefolder<config_dir>/skills/<name>Delivered
Codexfolder<config_dir>/skills/<name>Delivered
rules_mdcReserved extension point (no current agent type)
external_dirReserved extension point (no current agent type)

Folder delivery symlinks (copy-fallback) the master folder into the target, so the agent reads the canonical SKILL.md at <config_dir>/skills/<name>/SKILL.md.

external_dir and rules_mdc — reserved extension points. These are recognized SkillDeliveryMode values kept as intentional extension points; no current agent type uses them. Enabling a skill for a hypothetical agent with either mode raises SkillDeliveryUnsupported (HTTP 422) before any filesystem write; the follow / relink reconcilers skip such agents so registration, config-dir-change, and policy-change flows never fail.

Application service contracts (backend/coffer/application/skill/)

SkillService

MethodPurpose
import_local(path, actor) -> ResourceRead SKILL.md, validate, copy to master, register Resource, audit, return. Auto-binds for every registered agent (trust mode).
enable_for(skill_ref, agent_ref, force=False, actor) -> NoneUpsert binding, create symlink (or copy fallback on FAT32).
disable_for(skill_ref, agent_ref, actor) -> NoneMark binding disabled, remove link.
verify() -> DriftReportWalk every enabled binding; classify drift per DriftKind.
remove(ref, actor) -> NoneCascade-cleanup symlinks, delete master, delegate to ResourceService.delete.
cleanup_bindings_for_agent(agent_ref) -> NoneCalled by spec 004's agent.on_delete hook; removes all bindings + symlinks for that agent.

Workspace-amendment additions (implemented as free functions in unmanaged_ops.py / follow_ops.py, with binding_ops.py split out of service.py for the per-agent enable/disable flow — all conceptually private to the skill subpackage, same style as lifecycle_ops.py):

MethodPurpose
list_unmanaged(agent_name) -> list[UnmanagedView]FR-022 read-only scan over the agent's skill locations (see Unmanaged Skill above).
adopt_unmanaged(agent_name, skill_name, location, actor) -> ResourceFR-023: validate → move to ~/.coffer/skills/<name>/ → register → deliver the managed link to <config_dir>/skills/<name> → record an enabled binding; audits skill_adopted.
delete_unmanaged(agent_name, skill_name, location, actor) -> NoneFR-024: delete only that folder from disk; audits skill_unmanaged_deleted.
follow reconciliation (follow_ops.py)FR-025: reconcile deliveries on flag/exclusion changes and on skill register/remove; disabling preserves delivered skills as explicit bindings.

File viewer (application/skill/file_ops.py)

Stateless helpers beside service.py (same pattern as verify_ops.py) that expose a skill's master folder to surfaces. The read helpers (build_file_tree, read_skill_file) back the read-only in-app viewer and surface each node's absolute on-disk path so the UI can offer open-in-external-editor / reveal-in-file-manager affordances (FR-027); the UI viewer never edits content. A separate write helper (write_skill_file) backs the programmatic REST/CLI overwrite (FR-028) and is the only mutation here — the in-app UI does not call it to edit content. No DB, no audit; containment is enforced by resolving every candidate path and requiring it to stay inside the resolved master folder, reusing the path-escape approach from domain/skill/validator.py.

FunctionPurpose
build_file_tree(master_folder) -> FileNodeRecursively list the master folder; skip symlinks whose real target escapes the folder; never descend symlinked dirs. Each node carries its absolute on-disk path.
read_skill_file(master_folder, relpath) -> FileContentResolve master_folder/relpath, verify it stays inside the folder (else ValueError), read with a size cap, detect binary; returns the file's absolute path and containing folder's absolute path.
write_skill_file(master_folder, relpath, content) -> FileContentFR-028 programmatic (REST/CLI) overwrite of an existing text file under the same containment guard and size cap; refuses to create new files/dirs, write outside the folder, or overwrite a binary file; atomic. Not used by the in-app UI viewer.

File-node shape (FileNode / SkillFileNodeOut)

One node in the recursive tree. The root node has path == "".

FieldTypeNotes
namestrentry's base name
pathstrPOSIX path relative to the master folder root ("" for the root)
abs_pathstrabsolute on-disk path (for open-in-editor / reveal, FR-027)
type"file" | "dir"node kind
sizeint | Nonebyte size for files; null for directories
childrenlist[FileNode]populated for directories (sorted dirs-first then by name); [] for files

File-content shape (FileContent / SkillFileContentOut)

A single file's contents. The in-app viewer renders these read-only; the same shape is returned by the programmatic write (FR-028).

FieldTypeNotes
pathstrPOSIX path relative to the master folder root
abs_pathstrabsolute on-disk path of the file (FR-027)
folder_abs_pathstrabsolute on-disk path of the file's containing folder (FR-027)
contentstrfile text; empty ("") when binary is true
truncatedbooltrue when the file exceeded the 256 KiB read cap and only the prefix is returned
binarybooltrue when the file is non-UTF-8 or contains a NUL byte (content is empty)
sizeinttrue byte size of the file on disk (independent of any truncation)

SyncEngine (infrastructure/skill/sync_engine.py)

Cross-platform directory-link helper. Lives in infrastructure/ because its implementation talks directly to the host filesystem (and on Windows, to cmd.exe /c mklink); the application layer accesses it through a port defined in application/skill/ports.py.

MethodPurpose
make_directory_link(target: Path, link: Path) -> LinkModePOSIX: os.symlink(target, link, target_is_directory=True). Windows: try os.symlink first; on OSError(WinError 1314) fall back to subprocess.run(["cmd", "/c", "mklink", "/J", str(link), str(target)]) (junction). Returns LinkMode.SYMLINK | LinkMode.JUNCTION | LinkMode.COPY_FALLBACK.
remove_directory_link(link: Path) -> NoneDetect type then remove correctly (junction vs symlink vs copy-tree).
classify_target(link: Path, expected_master: Path) -> TargetStatusReturns the right DriftKind (or OK).

AgentSkillsValidator (domain/skill/validator.py)

Pure validator: given a folder path, returns ValidationOk(name, description) or ValidationError(reason, details). Checks: SKILL.md exists, frontmatter parses, name and description non-empty, no path-escape symlinks within the folder, total size ≤ 50 MB.

Composition root wiring

surfaces/http/app.py calls wire_agent_and_skill_kinds(app, resource_svc, audit, sm) from surfaces/http/agent_skill_wiring.py. The wiring function:

  1. Builds SkillBindingRepo, MasterStore, SyncEngine, and the SkillService (plus its verify_ops collaborator).
  2. Constructs the skill Kind via make_skill_kind(...) and registers it into app.state.kinds["skill"].
  3. Reads the existing agent Kind already registered by _wire_agent_kind and builds a new Kind whose on_delete is a closure: first await skill_svc.cleanup_bindings_for_agent(ref), then delegate to the original agent on_delete. The wrapped agent kind replaces the previous entry in app.state.kinds["agent"].
  4. Mounts the skill_routes router.

This closure-based composition keeps both kinds independent at the application layer (neither imports the other) and centralises cross-kind glue at the composition root.

Constraints summary

  • All HTTP loopback-only.
  • File-size limit: 50 MB total per skill folder, enforced by validate_skill_folder. The limit is a SkillService constructor default (size_limit_bytes); it is not yet plumbed to a config file, so v1 always uses the hardcoded 50 MB.