Skills
A skill is a folder with a SKILL.md whose YAML frontmatter carries a name and a description, following the agentskills.io specification. SkillsMiddleware scans a mount for those folders, and puts only the frontmatter into the system prompt: a short entry per skill giving the name, the description, and the path to read for the full instructions. The body stays on the backend until a request matches. That two-stage load is what progressive disclosure means, and the running example is built to make both stages visible.
What the demo does
The Run tab shows the prebuilt <chat> composition beside a panel titled Skill Index, which is empty until a run begins. The welcome suggestion asks whether a mid-size jet can operate out of KASE, and the panel fills in before the answer does: both skills are listed, runway-analysis and weather-brief, each with its description and a line reading "body not read", because both frontmatter blocks are in the prompt and neither body has been opened.
Then exactly one skill opens. runway-analysis picks up an outline, the path of its SKILL.md appears under it, and the path of the margin table that the SKILL.md sends the agent to appears a step later. The weather skill stays closed for the whole run. It is in the index so that a different question, the conditions at KSFO for example, has somewhere else to route.
How it is built
Three files carry the capability: a Python graph that seeds the skill mount, builds the agent, and republishes the middleware's private state, skills_metadata and skills_load_errors, an application config, and the Angular component that renders the panel. Open the Code tab to read them in place.
The names the graph and the panel agree on
The mount point, the store namespace, and the name of the custom stream event are module constants. SkillsMiddleware scans one level below the mount, so /skills/runway-analysis/SKILL.md is found and a deeper file is not.
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
SKILLS_DIR = Path(__file__).parent.parent / "skills"
#: Mount point the agent sees. `SkillsMiddleware` scans one level below it.
SKILLS_ROOT = "/skills/"
SKILLS_NAMESPACE = ("cockpit", "deep-agents-skills")
#: Custom stream event name the Angular panel listens for.
SKILLS_EVENT = "deep_agents.skills"A skill on disk
The frontmatter is the part the model sees first, so the description is written as a matching rule rather than as a summary, and its name must match the directory the file sits in, which is what makes the folder the unit rather than the file. This is the whole of skills/runway-analysis/SKILL.md:
---
name: runway-analysis
description: Decide whether a runway is long enough for a given aircraft at a given field elevation. Use when the user asks about runway suitability, takeoff or landing distance, or operating out of a high-elevation field.
license: MIT
---
# Runway Analysis
## When to use
The user is asking whether an aircraft can safely operate from a specific
runway, or is comparing two airports for a trip.
## Procedure
1. Get the field elevation and the longest runway length with
`lookup_field_elevation` and `lookup_runway_length`.
2. Read `/skills/runway-analysis/reference/margins.md` for the required margin
table. Do not work from memory — the table is the authority.
3. Compare the runway length against the required distance for the aircraft
class at that elevation.
4. State the verdict in one sentence, then give the two numbers you compared.
## Reporting
Always name the margin you applied. A verdict without the margin is not
reviewable.Step 2 is the second stage of the disclosure: the margin table costs nothing until the SKILL.md sends the agent to it.
Seeding the skill mount
SkillsMiddleware reads through the agent's backend, and which backend is a deployment decision. This demo runs on a shared public deployment, so a host-filesystem backend is out. The bundled folders are read once at import into a process-local InMemoryStore, which keeps the skill content in version control without giving the agent the host.
def _seed_skills_store() -> InMemoryStore:
"""Load the bundled skill folders into a process-local store.
Read once at import. The store is never written to at runtime, so the agent
sees `/skills/` as a stable read-only mount even though it is served by the
same `StoreBackend` machinery as a writable one.
"""
store = InMemoryStore()
backend = StoreBackend(namespace=lambda _runtime: SKILLS_NAMESPACE, store=store)
for path in sorted(SKILLS_DIR.rglob("*.md")):
# Paths are stored WITHOUT the mount prefix. `CompositeBackend` strips
# the route prefix before delegating, so a store seeded at
# `/skills/runway-analysis/...` would surface as
# `/skills/skills/runway-analysis/...` to the agent.
backend.write(f"/{path.relative_to(SKILLS_DIR).as_posix()}", path.read_text())
return store
SKILLS_STORE = _seed_skills_store()Nothing in the demo writes to the store after import, so /skills/ stays stable for the whole run, even though the machinery serving it is the same StoreBackend a writable mount would use.
Seed the store at /runway-analysis/SKILL.md, not /skills/runway-analysis/SKILL.md. The composite removes the matched prefix before delegating and re-adds it to the paths it returns, so a store seeded with the prefix surfaces to the agent as /skills/skills/runway-analysis/... and the scan finds nothing.
Mounting the skills beside the thread's own files
skills=["/skills/"] is what installs SkillsMiddleware, and it is installed with the agent's own backend, so where the skills live is decided by the backend argument rather than by the skills argument. CompositeBackend matches by path prefix, longest first: /skills/ resolves to the seeded store, and everything else falls through to StateBackend, so notes the agent writes stay on the thread and never touch the skill mount.
def build_skills_agent():
"""Build the skills agent.
`CompositeBackend` routes by path prefix, longest first. `/skills/` resolves
to the seeded store; everything else falls through to `StateBackend`, so any
notes the agent writes stay on the thread and never touch the skill mount.
"""
return create_deep_agent(
model=ChatOpenAI(model="gpt-4.1", temperature=0),
tools=[lookup_field_elevation, lookup_runway_length, lookup_weather],
system_prompt=(PROMPTS_DIR / "skills.md").read_text(),
backend=CompositeBackend(
default=StateBackend(),
routes={
SKILLS_ROOT: StoreBackend(
namespace=lambda _runtime: SKILLS_NAMESPACE,
store=SKILLS_STORE,
),
},
),
skills=[SKILLS_ROOT],
middleware=[SkillsVisibilityMiddleware()],
)
Telling the model the index is an index
The middleware appends its own guidance to the system message, including an instruction to read a skill's path with read_file before following it. The demo's own system prompt in prompts/skills.md says the same thing in the voice of the task, because a dispatcher that answers from recollection instead of from the margin table is the failure this example is about:
You are a dispatcher. Your procedures are not in this prompt — they are skills on
your filesystem under `/skills/`, and each one is a folder with a `SKILL.md`.
You have been given the name and description of every skill up front. That index
is deliberately short. When a request matches a skill, read its `SKILL.md` with
`read_file` before you start, and follow the procedure it gives you. If the
`SKILL.md` points at another file, read that too — the numbers in a reference
file are the authority, and your recollection is not.Announcing the private state keys
SkillsMiddleware declares skills_metadata and skills_load_errors annotated with PrivateStateAttr, so the loaded index is deliberately absent from the agent's declared input and output and never reaches the values stream. A panel bound to the settled state alone would therefore stay empty until the run finished. A small middleware announces both keys on a channel the client does receive while the run is going, guarded because get_stream_writer raises outside a streaming context.
class SkillsVisibilityMiddleware(AgentMiddleware):
"""Republish `skills_metadata` as a `custom` stream event.
Same shim as the memory capability's: the key stays private on the state and
is announced alongside it, so a panel can render the loaded skill index
while the agent works rather than only once the run settles.
"""
@property
def name(self) -> str:
return "SkillsVisibilityMiddleware"
def _emit(self, state: dict[str, Any]) -> None:
metadata = state.get("skills_metadata")
if metadata is None:
return
try:
writer = get_stream_writer()
except (RuntimeError, KeyError):
return
writer(
{
"name": SKILLS_EVENT,
"data": {
"skills_metadata": metadata,
"skills_load_errors": state.get("skills_load_errors") or [],
},
}
)
def before_model(self, state: dict[str, Any], runtime: Any) -> None: # noqa: ANN401, ARG002
self._emit(state)
return None
def after_agent(self, state: dict[str, Any], runtime: Any) -> None: # noqa: ANN401, ARG002
self._emit(state)
return None
This is the same application-side shim the memory capability uses, and it is worth naming as such rather than mistaking it for a framework feature.
Providing the agent
provideAgent() from @threadplane/langgraph registers the agent at the application root, and it is the only provider the <chat> composition requires. The example resolves its connection at runtime because the host that serves the demo decides which runtime is attached; your own application passes apiUrl and assistantId directly.
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
export const appConfig: ApplicationConfig = {
providers: [
provideAgent(() => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'langgraph') {
throw new Error('incompatible runtime');
}
return {
apiUrl: connection.apiUrl,
assistantId: connection.assistantId,
clientOptions: connection.clientOptions,
};
}),
],
};Two sources for one index
A custom event is a live signal and is not replayed when a thread is reopened. The thread state is durable but arrives only when the client hydrates it. A panel that wants the index during the run and after a reload reads both.
private readonly liveSkills = computed<Record<string, unknown>[] | null>(() => {
for (const event of [...this.agent.customEvents()].reverse()) {
if (event.name !== SKILLS_EVENT) continue;
const metadata = (event.data as { skills_metadata?: unknown } | undefined)?.[
'skills_metadata'
];
if (Array.isArray(metadata) && metadata.length > 0) {
return metadata as Record<string, unknown>[];
}
}
return null;
});
private readonly settledSkills = computed<Record<string, unknown>[] | null>(() => {
const metadata = (this.agent.value() as Record<string, unknown> | undefined)?.[
'skills_metadata'
];
return Array.isArray(metadata) && metadata.length > 0
? (metadata as Record<string, unknown>[])
: null;
});
protected readonly skillsSource = computed<'live' | 'checkpoint' | 'none'>(() => {
if (this.liveSkills()) return 'live';
return this.settledSkills() ? 'checkpoint' : 'none';
});agent.customEvents() holds the custom events of the current run, cleared when a new run starts, so the live reader walks it newest first and takes the most recent payload under the event name the graph emits, while agent.value() is the agent state the adapter projects from the latest checkpoint, which carries the key even though the run stream does not.
What the agent actually opened
The label the panel shows distinguishes the two sources rather than blending them: live means the visibility middleware announced the index during this run, and checkpoint is what a reopened thread looks like. The other half of the panel needs no shim at all. Reading a skill is a read_file call, an ordinary tool call on the runtime-neutral agent.toolCalls() Signal, so the paths are already on the client.
/** Absolute paths the agent has read with `read_file` during this run. */
private readonly openedPaths = computed<string[]>(() => {
const paths: string[] = [];
for (const call of this.agent.toolCalls()) {
if (call.name !== 'read_file') continue;
const path = (call.args as Record<string, unknown> | undefined)?.['file_path'];
if (typeof path === 'string' && !paths.includes(path)) paths.push(path);
}
return paths;
});Each entry of skills_metadata carries the path of its SKILL.md, and everything before the last slash of that path is the skill's directory, so matching the opened paths against that prefix is what attributes a read to a skill and is why the margin table counts as opening runway-analysis rather than as an unrelated file read.
protected readonly skills = computed<SkillEntry[]>(() => {
const source = this.liveSkills() ?? this.settledSkills() ?? [];
const opened = this.openedPaths();
return source.map((entry) => {
const path = String(entry['path'] ?? '');
const root = path.slice(0, path.lastIndexOf('/') + 1);
return {
name: String(entry['name'] ?? ''),
description: String(entry['description'] ?? ''),
path,
root,
opened: root ? opened.filter((candidate) => candidate.startsWith(root)) : [],
};
});
});The panel
The sidebar renders the source label, then one card per skill: the name, the description the model was given, and either the paths read or the words "body not read". An opened card carries data-opened="true", which is what draws the outline.
<div sidebar class="panel">
<h3 class="cap">Skill Index</h3>
<p class="source" data-testid="skills-source" [attr.data-source]="skillsSource()">
{{ skillsSource() === 'live' ? 'streamed live' : skillsSource() === 'checkpoint' ? 'from checkpoint' : 'no source yet' }}
</p>
@if (skills().length === 0) {
<p class="empty">No skills loaded</p>
}
@for (skill of skills(); track skill.name) {
<div
class="skill"
data-testid="skill"
[attr.data-name]="skill.name"
[attr.data-opened]="skill.opened.length > 0 ? 'true' : 'false'"
>
<span class="skill__name">{{ skill.name }}</span>
<p class="skill__description">{{ skill.description }}</p>
@if (skill.opened.length === 0) {
<p class="skill__files skill__files--idle">body not read</p>
} @else {
@for (file of skill.opened; track file) {
<p class="skill__files" data-testid="skill-open">{{ file }}</p>
}
}
</div>
}
<p class="hint">
Only the name and description above were put in the model's prompt. Everything else was
read on demand.
</p>
</div>Rendering both halves in one column is the point, because the index is what the prompt paid for and the read paths are what the request actually cost.
The skill that stays closed
The strongest evidence that a skills setup is working is the skill that does not get opened. If every skill's files are read on every request, the index is not routing anything and the descriptions are doing no work. That makes the descriptions, not the procedures, the part to iterate on: they are the only text the model sees before it chooses.
A test that only checks that runway-analysis was read passes just as happily when the agent reads everything. The example's end-to-end test asserts the negative too: weather-brief still carries data-opened="false" when the run is over.