Skip to main content

Skills

@flow-state-dev/orchestration — Markdown playbooks the agent loads on demand. Each skill is a folder with a SKILL.md and (optionally) supporting files. When a skill activates, its body is rendered into the generator's system prompt as specialized instructions for one kind of task.

What a skill is​

A skill is a folder on disk with a SKILL.md at its root:

skills/
check-news/
SKILL.md
competitor-analysis/
SKILL.md
research/
SKILL.md
reference/
methodology.md

The SKILL.md has YAML frontmatter (a description, plus optional settings like keywords, context, allowed-tools) and a Markdown body with the playbook. Supporting files in the folder ride along and are reachable from the body via the ${SKILL_DIR} substitution.

When to use a skill vs a capability​

Both extend what a generator can do. They solve different problems.

  • Capability. Code that ships with the app. Bundles resources, tools, context formatters, and helper functions. Always on.
  • Skill. Markdown authored by anyone, editable at runtime. Active only when matched.

Reach for a capability when the behavior is structural and always present (a memory store, an artifact system). Reach for a skill when the behavior is sometimes-on guidance that benefits from being editable without a deploy. The two compose — the Skills package itself is shipped as a capability.

A workforce has a third option, the package: Markdown instructions plus the blocks they need, handed to a worker as a folder. It is in the worker's prompt on every turn. If the guidance needs a tool the worker can't already call, write a package; otherwise write a skill. See Packages on disk.

Two ways a skill activates​

Skills are not always-on. Something has to decide a skill applies before its body lands in the system prompt.

  • Up-front (default in this package). A small pre-generator router — createSkillActivator — classifies the user message and writes any matched skills into session state. The main generator runs once with the body already in context. Three tiers, in order: literal /<skill-name> slash match, local keyword scan, then a fast LLM classifier when the earlier tiers don't decide.
  • Mid-flow. The agent sees a catalog of skills in its system prompt and calls a runSkill tool when one applies. Two provider hits per skill-active turn (decide, then run with skill in context).

Both paths can coexist. See Activation paths for the full breakdown — when to use which, the tier behavior, the preset toggles, and how to compose them.

By default, the names and descriptions of the skills an agent can load are supplied to it as context, so it knows they exist from its first step. In an app with many skills you can turn that off and let the agent look them up through discovery instead, which it pays for only when it asks.

What a matched skill does​

A matched skill is inline instructions. Its substituted body is injected into the parent generator's system prompt on the next step, and the conversation continues in the parent context with the parent's tools. That's the whole model.

A skill doesn't run a team of its own or work in a separate context. When work should go to someone else, put it on a task board and let the board's workers take it.

Binding skills to one generator​

The activation paths above write into session-wide state that every generator in the conversation reads. That's fine for a single agent. In a multi-agent flow it means one agent's skill leaks into another's context, and an activation persists for the rest of the conversation.

When you want a skill to belong to one generator — declared where that generator is defined, carried only by it — reach for the per-generator binding: a shared createSkillsLibrary plus generator({ uses: [skills.with({ active, dynamicActivation })] }). The common case is a one-line declarative active list, with no activate/deactivate lifecycle. See Per-generator binding for the full surface.

Wiring it up​

Add the capability to a generator via uses:. The capability installs a skills resource collection plus three presets (all on by default): tools (catalog tool schemas), context (the active-skill body formatter), and runSkill (the runSkill tool plus the catalog listing the model reads).

import { createSkillsCapability, readSkillsDirectory } from "@flow-state-dev/orchestration";
import { search, fetch, crawl } from "@flow-state-dev/tools";
import path from "node:path";
import { fileURLToPath } from "node:url";

const skillsDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"./skills",
);
const { skills: initialSkills } = await readSkillsDirectory(skillsDir);

export const skillsCap = createSkillsCapability({
catalog: { search: search(), fetch: fetch(), crawl: crawl() },
initialSkills,
scope: "user",
itemVisibility: { client: true, history: true }, // optional — see below
});

When you adopt up-front activation via createSkillActivator, drop the runSkill preset at the use site to skip the redundant tool-call path:

uses: [skillsCap.with({ runSkill: false }), skillActivator, /* ... */]

Then attach it to any generator:

import { generator } from "@flow-state-dev/core";

export const assistant = generator({
name: "assistant",
itemVisibility: { client: true, history: true },
model: "openai/gpt-6.1-sol",
prompt: "You are a helpful assistant. Active skills override defaults.",
uses: [skillsCap],
});

Notice the generator declares no tools:. A declared tools: is the complete set of tools the model may call, so a generator that has one gets neither the skills catalog's tools nor runSkill from the capability. When a generator needs its own tools: for other reasons, list the catalog's tools there too. See Tools a capability contributes.

The first time the collection is read (whether by skillActivator or by the catalog context formatter), the initial skills are seeded. Later edits to skill bodies — via DevTool, a CLI, or an admin UI — take effect on the next turn. There's no redeploy.

Main-agent scoping with itemVisibility​

In multi-agent patterns like planAndExecute, supervisor, and blackboard, a coordinator delegates steps to workers. If the skills capability rides along into every worker, every step pays for skill context, and workers redundantly know about skills the coordinator is the one matching.

The itemVisibility option on createSkillsCapability is an allowlist:

createSkillsCapability({ /* ... */, itemVisibility: { client: true, history: true } });

Set this way, the capability attaches only to generators with itemVisibility: { client: true, history: true } (or no itemVisibility set, treated as full visibility). Workers tagged itemVisibility: { client: true, history: false } skip it. The default is undefined — the capability attaches everywhere.

Feature-flag gating​

Wrap the capability in a dynamic uses: entry to gate it on a session flag. Users can toggle skills off when they want a plain chat:

import { defineCapability, type CapabilityRef } from "@flow-state-dev/core";
import { z } from "zod";

export const featuresCapability = defineCapability({
name: "features",
sessionStateSchema: z.object({
features: z.object({ skills: z.boolean().default(true) }).default({}),
}),
uses: [
(ctx) => {
const caps: CapabilityRef[] = [];
if (ctx.session.state.features.skills) caps.push(skillsCap);
return caps;
},
],
});

See the guide for a complete walkthrough.

Where skills folders live​

readSkillsDirectory takes a root, so a skills folder can sit wherever you point it. In an app that describes its workers in folders (see Workers on disk), a worker on a team has its skills spread across three folders: the whole app's, its team's, and any sitting beside the worker itself. An org worker, one under org/workers/, has two: the whole app's and its own. The same tree holds a team's shared documents, in resources/.

workforce/org/skills/triage/SKILL.md
workforce/teams/pentest/skills/port-scan/SKILL.md
workforce/teams/pentest/workers/recon/skills/sweep/SKILL.md

readWorkerSkills from @flow-state-dev/workforce/loader reads them for one worker:

import { readWorkerSkills } from "@flow-state-dev/workforce/loader";

const { skills, errors } = await readWorkerSkills("./workforce", {
team: "pentest",
worker: "recon",
});

Leave team out for an org worker, and it reads org/skills/ and org/workers/<worker>/skills/ instead.

Every skill folder at those levels is read, so nothing has to be listed anywhere for a skill to be included. The records come back level by level — org, then team when there is one, then the worker's own — and each one is the { name, skillMd, files } shape readSkillsDirectory returns. Skill names stay bare, with no team prefix.

Two calls naming different teams read different folders, so each result holds only what that call read. A review under teams/pentest/ and a review under teams/audit/ are two skills, and neither result carries the other's.

errors is one entry per thing that should have reached the worker and did not: a level that exists and cannot be listed, a skill folder that failed to load, a symlink on the way to a level, a SKILL.md the reader refuses, or one name reaching the worker from more than one level. Each entry is { kind, path, error }, keyed by a path relative to the root, and kind names which of those conditions it is, so a caller can tolerate one class and still refuse another. An absent level is empty rather than an error, but a root that cannot be read throws.

A collision carries one more field, paths. Narrow on kind to reach it:

for (const entry of errors) {
if (entry.kind === "duplicate-skill-name") {
// every file competing for the name: two or three, one per level
console.error(entry.error.message, entry.paths);
} else {
console.error(`${entry.kind} at ${entry.path}`, entry.error);
}
}

A contested name is dropped rather than resolved. Every copy stays out of skills, whichever level it came from: a folder beside the worker does not override its team's, and a team's does not override the org's. path on that entry is paths[0], the level the name was first seen at. Rename one or delete one to get the skill back.

readWorkerSkills returns records and installs nothing. Turning a set into a live catalog is the caller's job: pass skills as the initialSkills of the capability you build for that worker. See the @flow-state-dev/workforce README for the full surface.

What ships in the package​

ExportPurpose
createSkillsCapability(options)The one-line wiring path. Returns a capability with three presets — tools, context, runSkill — all on by default. Drop the tool-call path at the use site with cap.with({ runSkill: false }).
createSkillActivator(options)The up-front skill router. Returns a .tap-able sequencer. See Activation paths.
readSkillsDirectory(root)Walk a filesystem tree and return InitialSkill[] for initialSkills. Node only.
readWorkerSkills(root, { team, worker })One worker's skills across the org, team and worker levels of a workforce tree. Ships from @flow-state-dev/workforce/loader. Node only.
createRunSkillTool(options)The runSkill router as a standalone tool, for custom wiring outside the capability.
inlineActivateThe inline-mode handler, for custom wiring.
parseSkillMd, serializeSkillMdFrontmatter + body parsing, for tools that build skills programmatically.
skillActivationSourceSchema, matchedSkillSchemaRuntime Zod schemas mirroring the SkillActivationSource / MatchedSkill types from @flow-state-dev/core.

Continue to Activation paths for up-front vs. mid-flow, or Authoring skills for the SKILL.md format reference.