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 workflow node — no code edit required

Section titled “Attaching a skill to a workflow node — no code edit required”

The nodes that consume skills take skill as a normal param, so attaching one is a canvas edit rather than a code change:

  • triage — the skill drives the email→task decision and overrides the free-text instruction.
  • analyze — the skill acts as the keep/drop rubric for the pre-filter pass.

Set it in the node’s parameters on the /workflows canvas, or in the graph directly:

{
"id": "triage",
"type": "triage",
"params": {
"instruction": "",
"skill": "reply-only-triage"
}
}

The value is a skill id, a slug, or '' (no skill → falls back to the free-text instruction).

This used to be a schedule payload naming a preset and answering its declared skill input. Presets and the job schedule surface are both gone; the node’s params are the whole contract now.

  • Direct id/slug (what the nodes use): "skill": "reply-only-triage". The node 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 node that doesn’t read a skill (everything except triage and analyze today). The node has to resolve the ref itself — see resolveSkillSteering — and declare skill in its param schema.
  • A brand-new node — same pattern.
Job created → CreateJobUsecase.execute()
├─ resolveInputPlaceholders (${input:skill} → the id/slug value)
├─ resolveEnvPlaceholders (${env:KEY})
├─ resolveSettingPlaceholders (${setting:KEY})
└─ resolveSkillPlaceholders (${skill:id|slug} → instructions; unresolved → "")
Job persisted with resolved config
TriageNode / AnalyzeNode 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/skill/resolveSkillSteering.tsshared ref→steering resolver (pin, kind guard, audit)
apps/api/triage/jobs/TriageStep.tsconsumer (email triage)
apps/api/triage/adapters/{LlmTriageDeciderAdapter,FlueFormatTriageDecider}.tsevaluators
packages/platform-sdk/src/clients/SkillClient.tstyped SDK client
apps/dashboard/src/routes/skills.tsx (+ .new, .$id)dashboard editor
packages/platform-domain/src/workflows/entities/WorkflowTemplates.tsthe authored templates (skill params)