Skills
A Skill is a user-owned, versioned blob of LLM steering instructions stored in
Postgres (skills table). Skills are private per-user, optional, and let you swap the
reasoning of a pipeline step (today: triage, analyze and watch) without editing code.
A skill’s kind must match the slot it steers — triage for the triage step, enricher
for the analyze pre-filter. A mis-kinded skill is refused at run time and the step falls
back to its free-text instruction (recorded as fallbackReason: 'kind-mismatch'), so a
filter rule can’t silently be graded as a triage verdict.
History / rationale: see
tasks/unified-artifact-model.md(Phases 3a → 3b-4). This file is the practical “how to use” reference.
Anatomy
Section titled “Anatomy”| Field | Notes |
|---|---|
id | skill_<uuid> — owner-scoped |
slug | per-user-unique handle used in ${skill:slug} refs (immutable) |
name | display name |
description | one-line summary |
kind | triage | enricher | ingestor — pipeline slot (immutable) |
runtime | builtin (free-text) | flue (SKILL.md format) — picks evaluator |
definition | { instructions: string } — the markdown body |
enabled | disabled skills don’t resolve (fall back to free-text) |
version | bumped automatically when definition changes; pinnable via @N |
A flue SKILL.md looks like:
---name: Reply-only triagedescription: Decide which flagged threads need a follow-up---Create a task ONLY when the email asks me to do something concrete(reply, schedule, pay, review). Ignore newsletters, receipts, FYI.The parser (packages/platform-domain/src/skill/skillMarkdown.ts) is native — no Flue
runtime dependency. Frontmatter name/description and the body are woven into the
prompt by FlueFormatTriageDecider; builtin skills are passed as plain free-text by
LlmTriageDeciderAdapter.
Authoring a skill
Section titled “Authoring a skill”Dashboard: /skills (list), /skills/new (create), /skills/$id (edit).
Picking runtime = flue seeds a SKILL.md template. slug and kind are immutable
after creation; editing the definition bumps version.
SDK: SkillClient (packages/platform-sdk/src/clients/SkillClient.ts)
client.skills.list({ kind, runtime, enabled })client.skills.getById(id)client.skills.create({ name, kind, definition: { instructions }, slug?, runtime?, enabled? })client.skills.update(id, { ...partial }) // no slug / kindclient.skills.remove(id)REST: GET/POST /api/skills, GET/PATCH/DELETE /api/skills/:id.
Attaching a skill to a preset — no code edit required
Section titled “Attaching a skill to a preset — no code edit required”The presets that consume skills already expose skill as an optional input and wire
it into their step config as skill: '${input:skill}'
(packages/platform-domain/src/jobs/entities/JobPreset.ts):
gmailTriage—triagestep. The skill drives the email→task decision and overrides the free-textinstruction.classifiedsWatch—watchstep. The skill adds an optional LLM “is this a genuine deal?” veto on a numeric threshold match.
Attach a skill purely through the schedule payload — pass its id or slug:
POST /api/jobs/schedules{ "name": "my-rule-based-triage", "cron": "0 9 * * *", "template": { "preset": "gmailTriage", "inputs": { "gmailLabel": "Triage", "processedLabel": "triaged", "skill": "reply-only-triage" } }}In the dashboard, /scheduled-jobs/new renders the preset’s inputs, so “Triage skill”
appears as a normal form field — no hand-crafted payload.
skill value can be a skill id, a slug, or '' (no skill → falls back to the
free-text instruction).
Two reference styles
Section titled “Two reference styles”- Direct id/slug (what the presets use):
"skill": "reply-only-triage". The step resolves it against the repo at run time and respects itsruntime. ${skill:slug}placeholder: inlines the skill’s instructions text directly into a free-text field (e.g."instruction": "${skill:my-rules}"). Resolved at job creation byresolveSkillPlaceholdersinCreateJobUsecase; an unresolved ref becomes''(never leaks the literal placeholder into a prompt).
When you do need to touch code
Section titled “When you do need to touch code”- A preset that doesn’t expose a
skillinput (everything exceptgmailTriage/classifiedsWatchtoday). Addskill: '${input:skill}'to the step config plus askillentry in that preset’sinputsinJobPreset.ts. The consuming step must also actually resolve a skill — onlytriage,analyzeandwatchdo so today. - A brand-new preset / step — same pattern.
Resolution flow
Section titled “Resolution flow”Schedule fires → CreateJobUsecase.execute() ├─ applyInputDefaults (merge caller inputs with preset defaults) ├─ resolveInputPlaceholders (${input:skill} → the id/slug value) ├─ resolveEnvPlaceholders (${env:KEY}) ├─ resolveSettingPlaceholders (${setting:KEY}) └─ resolveSkillPlaceholders (${skill:id|slug} → instructions; unresolved → "") ↓Job persisted with resolved config ↓TriageStep / WatchStep at run time: resolve skill id/slug → entity → check version pin (@N; drift ⇒ log + free-text fallback) → pick evaluator by runtime (flue → FlueFormatTriageDecider, else LlmTriageDeciderAdapter)Guardrails
Section titled “Guardrails”- Quota: 50 skills/user —
POST /api/skillsreturns 429 at the cap. - Validation: empty instructions, or a
flueskill with no body below the frontmatter → 400 (enforced on create and PATCH). - Version pinning:
${skill:slug@N}or"skill": "slug@N"— on mismatch the step logs drift and degrades to the inline free-text instruction rather than running stale logic.
Key files
Section titled “Key files”| Path | Role |
|---|---|
packages/platform-domain/src/skill/entities/Skill.ts | entity, constants, validators |
packages/platform-domain/src/skill/skillMarkdown.ts | native SKILL.md parser |
packages/platform-domain/src/skill/ports/ISkillRepository.ts | persistence port |
apps/api/skill/ | routes + Drizzle/InMemory adapters + buildSkills |
apps/api/lib/drizzle/schema/skills.ts | skills table |
apps/api/jobs/usecases/commands/CreateJobUsecase.ts | resolveSkillPlaceholders |
apps/api/jobs/buildJobs.ts | wires skillsResolver |
apps/api/skill/resolveSkillSteering.ts | shared ref→steering resolver (pin, kind guard, audit) |
apps/api/triage/jobs/TriageStep.ts | consumer (email triage) |
apps/api/jobs/workers/steps/AnalyzeStep.ts | consumer (pre-filter, enricher kind) |
apps/api/triage/adapters/{LlmTriageDeciderAdapter,FlueFormatTriageDecider}.ts | evaluators |
apps/api/observation/jobs/WatchStep.ts | consumer (numeric watch veto) |
packages/platform-sdk/src/clients/SkillClient.ts | typed SDK client |
apps/dashboard/src/routes/skills.tsx (+ .new, .$id) | dashboard editor |
packages/platform-domain/src/jobs/entities/JobPreset.ts | preset definitions (skill input) |
