Skip to content

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.

FieldNotes
idskill_<uuid> — owner-scoped
slugper-user-unique handle used in ${skill:slug} refs (immutable)
namedisplay name
descriptionone-line summary
kindtriage | enricher | ingestor — pipeline slot (immutable)
runtimebuiltin (free-text) | flue (SKILL.md format) — picks evaluator
definition{ instructions: string } — the markdown body
enableddisabled skills don’t resolve (fall back to free-text)
versionbumped automatically when definition changes; pinnable via @N

A flue SKILL.md looks like:

---
name: Reply-only triage
description: 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.

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 / kind
client.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):

  • gmailTriagetriage step. The skill drives the email→task decision and overrides the free-text instruction.
  • classifiedsWatchwatch step. 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).

  • Direct id/slug (what the presets use): "skill": "reply-only-triage". The step resolves it against the repo at run time and respects its runtime.
  • ${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 by resolveSkillPlaceholders in CreateJobUsecase; an unresolved ref becomes '' (never leaks the literal placeholder into a prompt).
  • A preset that doesn’t expose a skill input (everything except gmailTriage / classifiedsWatch today). Add skill: '${input:skill}' to the step config plus a skill entry in that preset’s inputs in JobPreset.ts. The consuming step must also actually resolve a skill — only triage, analyze and watch do so today.
  • A brand-new preset / step — same pattern.
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)
  • Quota: 50 skills/user — POST /api/skills returns 429 at the cap.
  • Validation: empty instructions, or a flue skill 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.
PathRole
packages/platform-domain/src/skill/entities/Skill.tsentity, constants, validators
packages/platform-domain/src/skill/skillMarkdown.tsnative SKILL.md parser
packages/platform-domain/src/skill/ports/ISkillRepository.tspersistence port
apps/api/skill/routes + Drizzle/InMemory adapters + buildSkills
apps/api/lib/drizzle/schema/skills.tsskills table
apps/api/jobs/usecases/commands/CreateJobUsecase.tsresolveSkillPlaceholders
apps/api/jobs/buildJobs.tswires skillsResolver
apps/api/skill/resolveSkillSteering.tsshared ref→steering resolver (pin, kind guard, audit)
apps/api/triage/jobs/TriageStep.tsconsumer (email triage)
apps/api/jobs/workers/steps/AnalyzeStep.tsconsumer (pre-filter, enricher kind)
apps/api/triage/adapters/{LlmTriageDeciderAdapter,FlueFormatTriageDecider}.tsevaluators
apps/api/observation/jobs/WatchStep.tsconsumer (numeric watch veto)
packages/platform-sdk/src/clients/SkillClient.tstyped SDK client
apps/dashboard/src/routes/skills.tsx (+ .new, .$id)dashboard editor
packages/platform-domain/src/jobs/entities/JobPreset.tspreset definitions (skill input)