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 bytools/data_grid.py). It happens to live under theworkflow/package but has nothing to do with the retired graph feature.- Legacy DB rows.
workflows,workflow_runs,workflow_queue, andworkflow_approws 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:
namemust be kebab-case, ≤64 characters.descriptionis 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 byskills/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_refis aContentBlob.content_hash, not a numeric FK, the same CAS-by-hash conventiontools/acquisition.py:acquire_file_coreuses. A new version is a new row;Skill.active_versionmoves forward. Versions are append-only,skills/storage.py:add_versionrefuses 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 byskills/storage.py:write_bundle(path-confined, size-capped bysettings.skills_bundle_max_bytes).export_skill_mdreturns the stored bytes verbatim, never a re-serialization from DB columns, so the indexedname/descriptionfields 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
identitychunk (name+description). - One
triggerchunk pertriggersfrontmatter entry, an ask this skill answers, in the author’s words. - One
non_triggerchunk pernot_forentry, 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:
PrimitiveFactory(workflow/factory.py) still authors new primitives against real data, now reached via thecreate_primitive/validate_primitivetools intools/factory_tools.py(schemas inengine/schemas/factory.py) rather than the deleted workflow-assembly surface.create_primitivebuilds aPrimitiveFactory(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.- 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 standalonePrimitiverow, S1 in the ADR: skill scripts stay out of the global, cross-tenantprimitivestable). A script may dispatch exactly one tool,query_frames, via thePRIMITIVE_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:
| Tool | Purpose |
|---|---|
use_skill | Load 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_skill | Author 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_skill | Append a new version (full-replacement SKILL.md; never overwrites). |
run_skill_script | The one generic dispatcher for every skill’s every script, a script is never registered as its own named tool. |
read_skill_asset | Read 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).
Related Docs
- Engine, where
_pre_fetch_contextbuilds the[SUGGESTED CONTEXT]block that surfaces a ranked skill pointer - Tools, the broader tool-handler pattern
tools/skills.pyandtools/factory_tools.pyfollow - Glossary, primitive/contract terminology