All docs

Skills Module

Skills: reusable, versioned capabilities the assistant discovers and composes at runtime.

The Workflow feature (both the v1 legacy graph engine and the v2 primitive assembler, the plan_workflow/design_workflow/find_primitive/ assemble_workflow/repair_workflow tools, the modules workflow/engine.py, workflow/primitives.py, workflow/assembly.py, workflow/binding_inference.py, workflow/validator.py, workflow/auditor.py and workflow/router.py, and the /workflow sidebar page, all deleted) was retired in 2026-07. The product-owner decision was that the whole feature had effectively zero sustained use and that Agent Skills (the SKILL.md format) was the right re-conception of the same underlying idea: an authored, reusable, multi-step procedure.

What survives the retirement:

  • workflow/factory.py, PrimitiveFactory, the write → test → verify convergence loop that authors a new primitive against real data. Still live, now wired to the skills substrate rather than the deleted assembler.
  • workflow/seed_primitives.py, the atomic/operation-level primitives (load_dataset, filter_rows, write_to_sheet, etc.) seeded at startup.
  • workflow/spreadsheet/, an unrelated, still-live cell-first spreadsheet substrate (imported by tools/data_grid.py). It happens to live under the workflow/ package but has nothing to do with the retired graph feature.
  • Legacy DB rows. workflows, workflow_runs, workflow_queue, and workflow_app rows created before the retirement are retained, per the project’s no-DB-deletion rule, retiring a feature never means deleting the data it produced. There is no code path that reads or writes these tables anymore; they are historical record only.

What replaced it is the skills/ package described below.

What a Skill Is

A skill is a folder with a SKILL.md manifest, YAML frontmatter (name, description, and optionally triggers, not_for, scripts) followed by an instruction body in prose. This is the open agent-skills format (https://agentskills.io), also consumed by Claude Code, Codex, and others. Eunice adopts the format; execution is Eunice’s own, a skill authored here is portable as a human-readable procedure, but its execution guarantees (use_skill semantics, catalog-id data binding, namespaced script primitives) do not port to another harness.

Frontmatter is parsed by skills/storage.py:parse_skill_md. Requirements enforced there:

  • name must be kebab-case, ≤64 characters.
  • description is required.
  • triggers / not_for (optional) are lists of example asks in the author’s words, these become discovery chunks (see below), not just documentation.
  • scripts (optional, added after instruction-only skills shipped) declares script primitives bundled with the skill; validated at author time by skills/scripts.py:validate_script_specs.

Storage

A skill is a DB row plus a content-addressed bundle, there is no filesystem-of-folders model.

  • Skill (shared/database.py:Skill), id, tenant_id, owner_id, name, description, active_version, source (authored | imported | promoted), status (draft | active | quarantined), import_audit (JSON, non-null only for an imported multi-file bundle).
  • SkillVersion (shared/database.py:SkillVersion), one immutable row per version. bundle_ref is a ContentBlob.content_hash, not a numeric FK, the same CAS-by-hash convention tools/acquisition.py:acquire_file_core uses. A new version is a new row; Skill.active_version moves forward. Versions are append-only, skills/storage.py:add_version refuses to rename the skill and never overwrites a prior version.
  • The bundle itself (SKILL.md text, plus any references//assets// scripts/ files) is zipped deterministically and written into CAS by skills/storage.py:write_bundle (path-confined, size-capped by settings.skills_bundle_max_bytes). export_skill_md returns the stored bytes verbatim, never a re-serialization from DB columns, so the indexed name/description fields can never drift from what a caller actually reads back.

Visibility in the shipped version is owner-private: resolve_skill(db, tenant_id, owner_id, name) returns None for a skill that exists but isn’t owned by the caller, and callers must not distinguish “doesn’t exist” from “not yours” in their response. There is no sharing UI yet.

Discovery

Skills are discoverable the same way every other capability in the platform is, a system corpus, queried via vector search, not a keyword shortcut. skills/discovery.py registers a tenant-scoped skills corpus (one per tenant, unlike the system-wide self_code/experience corpora, because skill content is user-authored) and embeds, per skill:

  • One identity chunk (name + description).
  • One trigger chunk per triggers frontmatter entry, an ask this skill answers, in the author’s words.
  • One non_trigger chunk per not_for entry, an ask that sounds adjacent but isn’t this procedure.

rank_skills(container, tenant_id, owner_user_id, message, top_k) scores a message against the best matching identity/trigger chunk per skill, filters to status == "active" and owner_id == owner_user_id, and suppresses a skill whose best non_trigger match is within settings.skills_discovery_contrast of its best positive match, a contrastive check so an adjacent-but-different ask doesn’t load the wrong procedure. A skill named explicitly in the message (hazardous-plume, hazardous plume, etc., matched with word-boundary tolerance) always surfaces regardless of semantic score, longest name first.

This ranked pointer is surfaced the same way self_code/experience/the PD-3 project pointer are: via engine/prompt_builder.py’s [SUGGESTED CONTEXT] block, as a capped, on-demand candidate, never the full SKILL.md body.

Execution

skills/primitive_runtime.py is the extracted primitive-execution mechanism (the sandboxed PrimitiveContract / PrimitiveExecutor / PrimitiveRegistry, moved out of the deleted workflow/primitives.py verbatim as the one piece of the old feature worth keeping). It runs code in a subprocess worker pool (infrastructure/terminal/pyexec.py, governed by settings.subprocess_exec_primitives, which is on by default), enforces the same regex blocklist against imports/eval/exec/open/subprocess/network modules, and checks postconditions against a declarative grammar (<key-path> <op> <literal>) rather than eval(), a condition the grammar can’t express is reported as unverifiable, never silently passed.

Two ways a skill uses this:

  1. PrimitiveFactory (workflow/factory.py) still authors new primitives against real data, now reached via the create_primitive / validate_primitive tools in tools/factory_tools.py (schemas in engine/schemas/factory.py) rather than the deleted workflow-assembly surface. create_primitive builds a PrimitiveFactory(PrimitiveRegistry()) and hands it the operation description, target dataset, and expected behavior; the factory iterates (max 10 attempts, same-error short-circuit) until a working primitive is registered.
  2. A skill’s own scripts: (skills/scripts.py), declared in frontmatter, hydrated fresh from the skill’s active-version CAS bundle at call time (never registered as a standalone Primitive row, S1 in the ADR: skill scripts stay out of the global, cross-tenant primitives table). A script may dispatch exactly one tool, query_frames, via the PRIMITIVE_TOOL_CALL_ALLOWLIST, the same allowlist the retired spreadsheet-roundtrip primitives used, so a skill script can never become a passthrough to arbitrary system tools.

tools/skills.py is the chat-facing tool surface:

ToolPurpose
use_skillLoad an active, non-quarantined skill’s body as prose for the model to follow, composing existing tools live. Refuses a quarantined skill outright. Pages update_skill into the working set alongside the loaded body (so a revision request doesn’t dead-end into create_document), and, if the skill declares scripts, pages run_skill_script into a skill:<name> working-set namespace.
create_skillAuthor a new skill from raw skill_md, or from a from_conversation id (grouped via skills/authoring_capture.py:capture_conversation_steps, which reconstructs ordered user-message → tool-call groupings from telemetry, the same shape the retired save_as_workflow used to build, rebuilt fresh rather than lifted from the deleted file). Runs a non-blocking near-duplicate check against self_code + search_tools before creating.
update_skillAppend a new version (full-replacement SKILL.md; never overwrites).
run_skill_scriptThe one generic dispatcher for every skill’s every script, a script is never registered as its own named tool.
read_skill_assetRead one bundled file (references/…, etc.) from a skill’s active version, path-confined against traversal.

An imported skill (source="imported") is created with status = "quarantined" and its body cannot load via use_skill or discovery until a human promotes it (POST /skills/{id}/activate), this is the INV-6 review gate from the ADR.

REST Surface

skills/router.py, under /api/v1/skills: GET / (list), GET /{id} (detail, with ?version=), GET /{id}/export (raw SKILL.md bytes), POST /import / /import/zip / /import/github (quarantine-on-import), POST /{id}/versions (add a version), POST /{id}/activate / /deactivate. Capabilities: skill.read / skill.write / skill.review. The /skills sidebar page consumes this surface (browse, rendered SKILL.md, version strip, edit-as-new-version, import/export, a quarantine banner rendered as inert text, never executable).

  • Engine, where _pre_fetch_context builds the [SUGGESTED CONTEXT] block that surfaces a ranked skill pointer
  • Tools, the broader tool-handler pattern tools/skills.py and tools/factory_tools.py follow
  • Glossary, primitive/contract terminology