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 a short metadata index 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. Supported metadata can also contain optional license, compatibility, allowed-tools, and arbitrary metadata annotations; the React index displays these when present.
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 metadata entries are in the prompt and no read has yet been requested.
For the welcome scenario, the agent requests one skill. 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 Angular weather card has no matching requested path in the welcome scenario; this does not establish that its body was absent from all model context. 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
Both the Angular and React examples share the Python graph and skills_readonly.py enforcement module. The graph seeds the skill mount, builds the agent, and announces skills_metadata and skills_load_errors. Angular renders an index and requested paths; the React preview additionally distinguishes successfully inspected reads from requests, failures, and unavailable evidence. Open the Code tab to read the selected frontend alongside the complete shared backend.
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 name, description, and instruction path form the index 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 disclosure: the margin table body is loaded only when requested. Scanning the metadata index itself still requires backend reads.
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 through a writable backend. Runtime access is mounted
through `ReadOnlySkillsBackend`, which denies every mutation entry point.
"""
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()The seed uses a writable StoreBackend only at import. Runtime access goes through ReadOnlySkillsBackend, which denies writes, edits, and uploads synchronously and asynchronously. This keeps the shared /skills/ mount stable across threads; the default StateBackend remains writable for thread-local notes. Merely asking the model not to write would not enforce this boundary.
"""Read-only StoreBackend mount for bundled Skills on the shared deployment."""
from deepagents.backends import StoreBackend
from deepagents.backends.protocol import (
PERMISSION_DENIED,
DeleteResult,
EditResult,
FileUploadResponse,
WriteResult,
)
class ReadOnlySkillsBackend(StoreBackend):
"""Preserve StoreBackend reads while denying every mutation entry point.
Seed the store through a regular StoreBackend before mounting this class.
The async methods are overridden too: StoreBackend's async implementations
access the store directly rather than delegating to these sync methods.
"""
def write(self, file_path: str, content: str) -> WriteResult:
return WriteResult(error=PERMISSION_DENIED)
async def awrite(self, file_path: str, content: str) -> WriteResult:
return self.write(file_path, content)
def edit(
self, file_path: str, old_string: str, new_string: str, replace_all: bool = False
) -> EditResult:
return EditResult(error=PERMISSION_DENIED)
async def aedit(
self, file_path: str, old_string: str, new_string: str, replace_all: bool = False
) -> EditResult:
return self.edit(file_path, old_string, new_string, replace_all)
def delete(self, file_path: str) -> DeleteResult:
return DeleteResult(error=PERMISSION_DENIED)
async def adelete(self, file_path: str) -> DeleteResult:
return self.delete(file_path)
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
return [FileUploadResponse(path=path, error=PERMISSION_DENIED) for path, _ in files]
async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
return self.upload_files(files)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: ReadOnlySkillsBackend(
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, description, and instruction path 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 Skills metadata
SkillsMiddleware annotates skills_metadata and skills_load_errors with PrivateStateAttr, excluding them from declared input and output schemas. The local compiled graph exposes metadata in root values snapshots and saved checkpoints. In the observed deployed API stream, root values omit private Skills keys while saved checkpoints retain metadata, consistent with output-schema filtering. The optional skills_load_errors key can appear when errors are produced, and successful loads can omit it. SkillsVisibilityMiddleware includes both keys in its named custom event, with an empty-list error fallback and a guard because get_stream_writer raises outside streaming. Angular uses this event for its live index. React reads native root values and, when the public stream omits metadata, the confirmed saved checkpoint. The custom event does not supply React index authority or confirmed-read proof. React confirmation allows only absent live private Skills keys; present private values, every ordinary value, messages, tools and terminal controls must agree with the exact checkpoint. Current state, newest history and the exact checkpoint must agree fully, as must values after native load. Saved checkpoint metadata remains authoritative.
class SkillsVisibilityMiddleware(AgentMiddleware):
"""Republish `skills_metadata` as a `custom` stream event.
Skills keys are excluded from declared input/output schemas. Local compiled
root values expose metadata; observed deployed API values omit private keys.
Saved checkpoints remain authoritative. Optional load errors can appear when
produced and can be omitted on normal loads. This live index event includes
both keys, with an empty-list fallback for errors; it does not prove a
confirmed read and makes no claim about Memory visibility.
"""
@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 an application-side visibility event rather than a framework feature. The Skills contracts establish this behavior for the pinned Skills graph; they make no inference about the separate Memory capability.
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. Root values can also carry the index during a run, and the saved checkpoint carries it for hydration. The Angular panel prefers the named custom event and otherwise reads agent.value(). Its checkpoint label identifies that fallback, without proving the value came only from a reopened thread.
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 may include the key from root values or a checkpoint.
Requested paths and successfully inspected reads
The Angular live label means the visibility middleware announced the index; checkpoint means the value fallback supplied it. Its path projection reads read_file arguments from the runtime-neutral agent.toolCalls() Signal. These are requested paths: a failed read can still appear, and the panel does not inspect the result or establish successful body disclosure. The React preview separates requests from successfully inspected matching tool results and exposes failed or unavailable evidence explicitly.
/** Requested read_file paths, without validating completion or returned content. */
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 metadata entry carries its SKILL.md path. Angular derives the directory prefix and attributes requested paths under it to that skill. This prefix match associates the margin-table request with runway-analysis; it does not validate returned content. React validates canonical paths and matching successful tool results before marking content inspected.
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 requested paths 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>In Angular, "body not read" means no matching read request was observed. It is not evidence that the body was absent from all model context. In React, read evidence is independent of index authority: archived evidence does not authorize another Send, and body content is shown as inspected only after a successful matching result.
The skill that stays closed
A useful routing check is that an unrelated skill has no matching read request or successful result in the observed root run. 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 guide selection alongside the name and instruction path in the index.
The Angular end-to-end test checks both markers: runway-analysis has a matching requested path and weather-brief carries data-opened="false" when the run is over. The negative means no matching weather path request was observed; it does not prove body absence from model context or successful inspection of the selected skill.