> ## Documentation Index
> Fetch the complete documentation index at: https://launchdarkly.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent skills

<View title="Developer" />

<View title="Federal docs" />

<View title="EU docs" />

This topic explains how to use agent skills with LaunchDarkly AI SDKs. This feature is available for AI SDKs only.

An agent skill is a single versioned `SKILL.md` document, with YAML front matter plus a Markdown body, that you manage in LaunchDarkly. Skills live in a project-scoped library, and you can attach them to an AgentControl config variation. [LaunchDarkly AI SDKs](/docs/sdk/ai) let your application do various things with a skill:

* Discover which skills a config variation references after LaunchDarkly resolves it.
* Retrieve a skill's content after LaunchDarkly verifies it.
* Materialize skills as files, which means agent runtimes, such as the Claude Agent SDK, can read them directly from disk.

## How agent skills work

Keep the following in mind as you build with agent skills:

* **A skill reference pins a version:** A config variation names a skill reference in the form of a `{ key, version }` pair. The SDK resolves the pinned version. A referenced skill has no "latest" version.
* **Skills have no targeting of their own:** The skill a context receives is decided entirely by which config variation LaunchDarkly serves that context. Content accessors do not take a context argument.
* **Content is opaque bytes:** A skill's content is `bytes` in Python and `Uint8Array` in Node.js. The SDK never parses it and does not expose a frontmatter accessor. If you need the front matter, decode and parse it manually. A skill's `name` and `description` come from metadata that LaunchDarkly stores, not from the content. This means they are available without decoding anything.
* **Integrity is verified before anything is returned:** Every skill's SHA-256 hash is checked against the hash LaunchDarkly delivered. Content that does not verify is withheld and is never returned or written to disk.
* **Skills are server-side only:** Skill content is customer-confidential agent instruction text. Mobile keys and client-side IDs are rejected.

## Prerequisites

To use agent skills, you need:

* A LaunchDarkly project with agent skills enabled and at least one skill created. To learn more, read [Agent skills](/docs/home/agentcontrol/agent-skills.md).
* The environment's server-side SDK key.
* The [Python AI SDK](/docs/sdk/ai/python) `launchdarkly-ai-server` 0.3.0 or later with Python 3.12 or later, or the [Node.js (server-side) AI SDK](/docs/sdk/ai/node-js) `@launchdarkly/ai-server` 0.4.0 or later with Node.js 20 or above. Earlier versions do not support agent skills.
* An agent runtime or provider integration that consumes skills, matching one of the patterns described in the [Agent skill integration patterns](#agent-skill-integration-patterns) section.

## Install the SDK

Agent skills ships inside the existing AI SDK server packages. You do not install a separate package, but you must use a version that supports agent skills:

```bash title="Shell (Python)" lines wrap theme={null}
pip install "launchdarkly-ai-server>=0.3.0"
```

```bash title="Shell (Node.js)" lines wrap theme={null}
npm install "@launchdarkly/ai-server@>=0.4.0"
```

## Agent skill integration patterns

The pattern you need decides which part of the API you use.

<table>
  <thead>
    <tr>
      <th>Pattern</th>
      <th>Use cases</th>
      <th>SDK outputs</th>
      <th>API</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>**Filesystem agents**</td>
      <td>Claude Agent SDK, Codex, or anything that discovers a `skills/` directory</td>
      <td>Skill files written to disk before the agent starts, kept current with LaunchDarkly</td>
      <td>`write_skills`/`writeSkills`, optionally `watch_skills`/`watchSkills`</td>
    </tr>

    <tr>
      <td>**Dynamic loaders**</td>
      <td>Custom harnesses, LangChain-style skill-loading tools</td>
      <td>Verified skill bytes on demand at runtime</td>
      <td>`get_skill`/`getSkill` and related functions</td>
    </tr>

    <tr>
      <td>**Provider-uploaded references**</td>
      <td>Anthropic's `container.skills`, OpenAI skill references</td>
      <td>The list of pinned skill references on the resolved variation, to pass through in the model call. No content is fetched.</td>
      <td>`skill_refs`/`skillRefs` only. Needs no delivery setup.</td>
    </tr>
  </tbody>
</table>

Provider-uploaded references do not need a skill store. `skill_refs`/`skillRefs` works on any resolved config, including a config evaluated with a client you supply yourself. If that is the only pattern you need, skip to [Discover references on a resolved config](#discover-references-on-a-resolved-config).

## AI SDKs

This feature is available for the following AI SDKs:

* [Python AI](#python-ai)
* [Node.js (server-side) AI](#node-js-server-side-ai)

### Python AI

> **Expand Python AI SDK code sample**
>
> #### Set up delivery
>
> Filesystem and dynamic-loader patterns need skill content, which arrives through a skill store that you create and configure with `set_skill_store`. The production store is `FDv2SkillStore`. Start it, wait for the first payload, then set it as the skill store:
>
> ```python title="Python AI SDK" lines wrap theme={null}
> import os
> from launchdarkly_ai_server import init_client
> from launchdarkly_ai_server.experimental.skills import FDv2SkillStore, set_skill_store
>
> store = FDv2SkillStore(os.environ["LD_SDK_KEY"]).start()
> if not store.wait_for_skills(timeout=10):
>     # No payload arrived. Reconciling now would find an empty store.
>     log.warning("skill delivery has not answered: %s", store.failed or "still waiting")
> set_skill_store(store)
> await init_client()
> # ... at shutdown:
> store.close()
> ```
>
> Check the return value of `wait_for_skills`. It is `false` when the wait times out, when the store is closed, and when delivery has failed fatally. A fatal error, such as an unauthorized key, resolves `wait_for_skills` immediately rather than at the timeout. This means a boot gated on it does not proceed against a dead store. Read `store.failed` to distinguish between the SDK giving up and the SDK timing out.
>
> During an outage, the store keeps serving the last content it received. Retrieval never blocks on the network. Calling `get_skill` immediately after `start()` can see an empty store, which is why `wait_for_skills` exists.
>
> `close()` is final. A closed store still answers from content it already received, but delivery cannot be restarted. Construct a new store to resume.
>
> #### Use an in-memory store for local development and tests
>
> `InMemorySkillStore` is a dict-backed store for local development, tests, and bring-your-own-content. Put wire-shaped objects into it, including a correct `contentHash` (SHA-256, lowercase hex, over the verbatim UTF-8 bytes). Content whose hash does not match is withheld.
>
> As an example, this constructs a store and adds a skill to it:
>
> ```python title="Python AI SDK" lines wrap theme={null}
> import hashlib
> from launchdarkly_ai_server.experimental.skills import InMemorySkillStore, set_skill_store
>
> SKILL_MD = "---\nname: PDF Extraction\n---\nExtract text from PDFs.\n"
> store = InMemorySkillStore()
> store.put({
>     "key": "pdf-extraction",
>     "version": 2,
>     "content": SKILL_MD,
>     "contentHash": hashlib.sha256(SKILL_MD.encode("utf-8")).hexdigest(),
> })
> set_skill_store(store)
> ```
>
> #### Discover references on a resolved config
>
> `skill_refs` is a pure projection of the config the SDK already returns from `inspect_config`. It performs no input/output (I/O) and needs no store or client.
>
> For example, this calls `skill_refs` on the resolved config to get its references:
>
> ```python title="Python AI SDK" lines wrap theme={null}
> from launchdarkly_ai_server import inspect_config
> from launchdarkly_ai_server.experimental.skills import skill_refs
>
> info = await inspect_config("doc-agent", {"kind": "user", "key": "user-123"})
> if info["config"] is not None:   # None if the config could not be resolved; skill_refs raises ValueError on it
>     refs = skill_refs(info["config"])   # [SkillReference(key='pdf-extraction', version=2)]
> ```
>
> To pass references through to a provider, map each `(key, version)` pair to the provider's own skill ID:
>
> ```python title="Python AI SDK" lines wrap theme={null}
> skills = []
> for r in refs:
>     skill_id, provider_version = PROVIDER_SKILLS[(r.key, r.version)]
>     skills.append({"type": "custom", "skill_id": skill_id, "version": provider_version})
>
> response = anthropic.beta.messages.create(..., container={"skills": skills})
> ```
>
> #### Retrieve content
>
> ```python title="Python AI SDK" lines wrap theme={null}
> from launchdarkly_ai_server.experimental.skills import get_skill, get_skills, all_skills, get_skill_result
>
> skill = await get_skill("pdf-extraction")               # newest available, or None
> pinned = await get_skill("pdf-extraction", version=2)   # exactly version 2, or None
> batch = await get_skills(refs)                          # input order; misses omitted
> everything = await all_skills()                         # one per key, newest version
> text = skill.content.decode("utf-8") if skill else None
> ```
>
> `get_skill` returns `None` rather than raising when a skill is unavailable for any reason. The function only raises an exception if no store is configured. `get_skills` accepts references or bare key strings, where a string means newest, and omits any entry that is missing or unverifiable.
>
> Use `get_skill_result` when you need to distinguish an absent skill from a tampered one:
>
> ```python title="Python AI SDK" lines wrap theme={null}
> outcome = await get_skill_result("pdf-extraction")
> if outcome.reason == "integrity_failure":
>     raise SystemExit(f"refusing to start: {outcome.detail}")
> elif outcome.reason == "store_unavailable":
>     ...  # outage, not revocation: retry or run degraded
> elif outcome.reason in ("absent", "wrong_version"):
>     ...  # carry on without the skill
> else:
>     print(outcome.skill.content)
> ```
>
> To learn what each `reason` means, read [Distinguish an absent skill from a tampered one](#distinguish-an-absent-skill-from-a-tampered-one).
>
> #### Materialize skills onto disk
>
> The documented default is the config-scoped form. Materialize only what the resolved variation references into a directory your agent runtime reads. LaunchDarkly calls this directory the managed root. The SDK owns the files beneath it that its manifest lists, and only ever writes or deletes paths the manifest already tracks.
>
> Here's an example that materializes the resolved skills to disk:
>
> ```python title="Python AI SDK" lines wrap theme={null}
> from pathlib import Path
> from launchdarkly_ai_server import init_client, inspect_config
> from launchdarkly_ai_server.experimental.skills import set_skill_store, skill_refs, write_skills
>
> set_skill_store(store)
> await init_client()
> info = await inspect_config("doc-agent", context)
>
> Path(".claude").mkdir(exist_ok=True)         # the root's parent must already exist
> if info["config"] is None:
>     log.error("config unavailable; skipping reconcile")
> else:
>     report = await write_skills(skill_refs(info["config"]), ".claude/skills")
>     for action in report.errors:
>         log.error("skill %s: %s", action.key or "<run>", action.error)
>
> run_agent()   # the agent discovers .claude/skills/<key>/SKILL.md
> ```
>
> `write_skills` is `async` for parity with the rest of the SDK, but it performs synchronous filesystem I/O. Wrap it in `asyncio.to_thread` if that matters to your application, and reconcile one root at a time.
>
> To keep the managed root reconciled as skills change, use `watch_skills`. It runs `write_skills` once, then re-runs it whenever delivery changes. With a streaming store and `"*"`, a revoked skill has its `SKILL.md` pruned from the managed root instead of at the next restart. With an explicit list, such as `skill_refs(info["config"])`, a skill deleted in LaunchDarkly is reported as an `error` action, and its `SKILL.md` is kept instead of pruned. Because the watcher only listens to the skill store and not to flag changes, it does not detect a skill removed from a variation.
>
> The following example keeps the root synced with a watcher:
>
> ```python title="Python AI SDK" lines wrap theme={null}
> from pathlib import Path
> from launchdarkly_ai_server import inspect_config
> from launchdarkly_ai_server.experimental.skills import skill_refs, watch_skills
>
> info = await inspect_config("doc-agent", context)
> if info["config"] is None:
>     log.error("config unavailable; skipping reconcile")
>     run_agent()
> else:
>     Path(".claude").mkdir(exist_ok=True)         # the root's parent must already exist
>     report, watcher = await watch_skills(skill_refs(info["config"]), ".claude/skills")
>     try:
>         run_agent()
>     finally:
>         watcher.close()
> ```
>
> Use one watcher per root, and do not call `write_skills` by hand against a root that has a watcher. To learn what `write_skills` does and does not do, read [Materialize skills safely](#materialize-skills-safely).

### Node.js (server-side) AI

> **Expand Node.js (server-side) AI SDK code sample**
>
> #### Set up delivery
>
> Filesystem and dynamic-loader patterns need skill content, which arrives through a skill store that you create and configure with `setSkillStore`. The production store is `FDv2SkillStore`. Start it, wait for the first payload, then set it as the skill store:
>
> ```typescript title="Node.js AI SDK" lines wrap theme={null}
> import { initClient } from '@launchdarkly/ai-server';
> import { FDv2SkillStore, setSkillStore } from '@launchdarkly/ai-server/experimental';
>
> const store = new FDv2SkillStore(process.env.LD_SDK_KEY!).start();
> if (!(await store.waitForSkills({ timeoutSeconds: 10 }))) {
>   console.warn(`skill delivery has not answered: ${store.failed ?? 'still waiting'}`);
> }
> setSkillStore(store);
> await initClient();
> // ... at shutdown:
> await store.close();
> ```
>
> Check the return value of `waitForSkills`. It is `false` when the wait timed out, when the store was closed, and when delivery has failed fatally. A fatal error, such as an unauthorized key, resolves `waitForSkills` immediately rather than at the timeout. This means a boot gated on it does not proceed against a dead store. Read `store.failed` to distinguish between the SDK giving up and the SDK timing out.
>
> During an outage, the store keeps serving the last content it received. Retrieval never blocks on the network. Calling `getSkill` immediately after `start()` can see an empty store, which is why `waitForSkills` exists.
>
> `close()` is final. A closed store still answers from content it already received, but delivery cannot be restarted. Construct a new store to resume.
>
> #### Use an in-memory store for local development and tests
>
> `InMemorySkillStore` is a dev/test store with a `put()` method. Put wire-shaped objects into it, including a correct `contentHash` (SHA-256, lowercase hex, over the verbatim UTF-8 bytes). Content whose hash does not match is withheld.
>
> As an example, this constructs a store and adds a skill to it:
>
> ```typescript title="Node.js AI SDK" lines wrap theme={null}
> import { InMemorySkillStore, setSkillStore } from '@launchdarkly/ai-server/experimental';
>
> const store = new InMemorySkillStore();
> store.put({
>   key: 'pdf-extraction',
>   version: 2,
>   content: skillMd,
>   contentHash: computedSha256Hex,
> });
> setSkillStore(store);
> ```
>
> #### Discover references on a resolved config
>
> `skillRefs` is a pure projection of the config the SDK already returns from `inspectConfig`. It performs no I/O and needs no store or client.
>
> For example, this calls `skillRefs` on the resolved config to get its references:
>
> ```typescript title="Node.js AI SDK" lines wrap theme={null}
> import { inspectConfig } from '@launchdarkly/ai-server';
> import { skillRefs } from '@launchdarkly/ai-server/experimental';
>
> const info = await inspectConfig('doc-agent', { kind: 'user', key: 'user-123' });
> if (info.config) {   // null if the config could not be resolved; skillRefs throws on it
>   const refs = skillRefs(info.config);   // [{ key: 'pdf-extraction', version: 2 }]
> }
> ```
>
> To pass references through to a provider, map each `(key, version)` pair to the provider's own skill ID, the same way you would in the Python AI SDK.
>
> #### Retrieve content
>
> ```typescript title="Node.js AI SDK" lines wrap theme={null}
> import { getSkill, getSkills, allSkills, getSkillResult } from '@launchdarkly/ai-server/experimental';
>
> const newest = await getSkill('pdf-extraction');
> const pinned = await getSkill('pdf-extraction', { version: 2 });
> const batch = await getSkills(refs);
> const everything = await allSkills();
> const text = new TextDecoder().decode(newest?.content);
> ```
>
> `getSkill` returns `null` rather than throwing when a skill is unavailable for any reason. The function only throws an exception if no store is configured. `getSkills` accepts references or bare key strings, where a string means newest, and omits any entry that is missing or unverifiable.
>
> Use `getSkillResult` when you need to distinguish an absent skill from a tampered one. To learn what each `reason` means, read [Distinguish an absent skill from a tampered one](#distinguish-an-absent-skill-from-a-tampered-one).
>
> #### Materialize skills onto disk
>
> The documented default is the config-scoped form. Materialize only what the resolved variation references into the managed root that your agent runtime reads.
>
> Here's an example that materializes the resolved skills to disk:
>
> ```typescript title="Node.js AI SDK" lines wrap theme={null}
> import { initClient, inspectConfig } from '@launchdarkly/ai-server';
> import { setSkillStore, skillRefs, writeSkills } from '@launchdarkly/ai-server/experimental';
>
> setSkillStore(store);
> await initClient();
> const info = await inspectConfig('doc-agent', context);
>
> if (!info.config) {
>   console.error('config unavailable; skipping reconcile');
> } else {
>   const report = await writeSkills(skillRefs(info.config), '.claude/skills');
>   if (!report.ok) {
>     for (const action of report.errors) console.error(`skill ${action.key}: ${action.error}`);
>   }
> }
> ```
>
> To keep the managed root reconciled as skills change, use `watchSkills`. It runs `writeSkills` once, then re-runs it whenever delivery changes. With a streaming store and `'*'`, a revoked skill's `SKILL.md` is pruned from the managed root within seconds instead of at the next restart. With an explicit list, such as `skillRefs(info.config)`, a skill deleted in LaunchDarkly is reported as an `error` action, and its `SKILL.md` is kept instead of pruned. Because the watcher listens only to the skill store and not to flag changes, it does not detect a skill removed from a variation.
>
> The following example keeps the root synced with a watcher:
>
> ```typescript title="Node.js AI SDK" lines wrap theme={null}
> import { inspectConfig } from '@launchdarkly/ai-server';
> import { skillRefs, watchSkills } from '@launchdarkly/ai-server/experimental';
>
> const info = await inspectConfig('doc-agent', context);
> if (!info.config) {
>   console.error('config unavailable; skipping reconcile');
>   // run the agent
> } else {
>   const { watcher } = await watchSkills(skillRefs(info.config), '.claude/skills');
>   try {
>     // run the agent
>   } finally {
>     await watcher.close();
>   }
> }
> ```
>
> Use one watcher per root, and do not call `writeSkills` by hand against a root that has a watcher. To learn what `writeSkills` does and does not do, read [Materialize skills safely](#materialize-skills-safely).

## Store options

The skill store accepts the following options to control delivery mode, endpoints, and retry behavior. Each option has a different name in Python and Node.js:

<table>
  <thead>
    <tr>
      <th>Python</th>
      <th>Node.js</th>
      <th>Description</th>
      <th>Default value</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>`mode`</td>
      <td>`mode`</td>
      <td>`"stream"` or `"poll"`.</td>
      <td>`"stream"`</td>
    </tr>

    <tr>
      <td>`base_uri`</td>
      <td>`baseUri`</td>
      <td>Must be `https://`. Where poll requests are sent. Override for private instances or proxies that serve the FDv2 endpoints. Passed on its own, it applies to both polling and streaming, which is what a single-host relay or private instance needs.</td>
      <td>`https://sdk.launchdarkly.com`</td>
    </tr>

    <tr>
      <td>`stream_uri`</td>
      <td>`streamUri`</td>
      <td>Must be `https://`. Where streaming requests are sent. Polling and streaming are separate hosts at LaunchDarkly, which means the defaults are a pair. Set this as well as `base_uri` only when the two differ.</td>
      <td>`https://stream.launchdarkly.com`</td>
    </tr>

    <tr>
      <td>`poll_interval` (seconds)</td>
      <td>`pollIntervalSeconds`</td>
      <td>Poll mode only.</td>
      <td>30 seconds</td>
    </tr>

    <tr>
      <td>`initial_backoff`, `max_backoff` (seconds)</td>
      <td>`initialBackoffSeconds`, `maxBackoffSeconds`</td>
      <td>Reconnect backoff. `max_backoff` caps every delay between retries, including one the server asks for with `Retry-After`. Recoverable failures are retried for as long as the store runs. Only a fatal error, such as an unauthorized key, stops delivery, and `failed` reports it.</td>
      <td>1s / 30s</td>
    </tr>

    <tr>
      <td>`read_timeout` (seconds)</td>
      <td>`readTimeoutSeconds`</td>
      <td>The single network timeout in both SDKs. In poll mode, it bounds the whole request. In stream mode, it bounds the wait for the next bytes, which means a stream that goes quiet past it reconnects rather than hanging. There is no separate connect timeout.</td>
      <td>10s in poll mode, 300s in stream mode</td>
    </tr>
  </tbody>
</table>

> [!WARNING]
>
> **The watcher debounce uses different time units**
>
> Store options, `wait_for_skills`/`waitForSkills`, and the `timeout` option on `write_skills`/`writeSkills` are in seconds in both languages. The `watch_skills`/`watchSkills` debounce is the exception. `debounce` takes 0.5 seconds in Python, and `debounceMs` takes 500 milliseconds in Node.js, in the same options object as `timeout`.

## Materialize skills safely

The following explains what materializing does and does not do:

* Layout is `<root>/<skill-key>/SKILL.md`, matching the agentskills.io convention. This means you can point the root straight at `.claude/skills/` or an equivalent directory.
* Materializing is a reconciliation. `write_skills`/`writeSkills` records what it owns in a manifest at `<root>/.launchdarkly-skills.json` and only ever overwrites or deletes paths that the manifest lists. If you place a file yourself at a managed path, it's reported as an `error` and left untouched. If you do not commit materialized skills, add the manifest to `.gitignore`.
* A byte-identical file is adopted. If an unmanaged file's bytes already exactly match the content LaunchDarkly resolved, it is adopted into the manifest and reported `skipped_current`. This is what makes a reconcile recoverable after a process is killed between writing a skill file and rewriting the manifest. Bytes that differ in any way are still refused.
* LaunchDarkly content takes precedence at execution time. A managed file whose bytes differ from the resolved content, whether from a stale version or a local edit, is overwritten and reported `updated`.
* The root's parent must exist. The call creates the leaf root directory only. A missing parent, a root that is a file, or a root that is a symlink is a caller error, distinct from a per-skill `error` action.
* A requested skill that was never delivered is an error. In that case, `report.ok` is `false`. A pinned reference resolving to nothing is not a silent success.
* A store with no first payload does not prune. The run reports the retrieval as unavailable and leaves disk untouched, rather than reading an empty store as a full revocation.
* `"*"` materializes the entire project library. This is an explicit opt-in that puts every skill's description into the agent's context, including skills no config references. Present the config-scoped form as the default, and `"*"` as a choice.

### Options

The `write_skills`/`writeSkills` function accepts the following options to control pruning, timeout, and how it behaves when retrieval is unavailable:

<table>
  <thead>
    <tr>
      <th>Option</th>
      <th>Default value</th>
      <th>Description</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>`prune`</td>
      <td>`True`</td>
      <td>Remove previously managed skills that are no longer in the requested set. This is also how revocation reaches disk.</td>
    </tr>

    <tr>
      <td>`timeout`</td>
      <td>10 seconds, both languages</td>
      <td>Total budget for the call, including retrieval.</td>
    </tr>

    <tr>
      <td>`on_unavailable`/`onUnavailable`</td>
      <td>`"keep"`</td>
      <td>`"keep"` leaves last-known-good managed files in place and reports the failure. `"raise"` raises or throws instead, for callers that refuse to start on stale content.</td>
    </tr>
  </tbody>
</table>

## Distinguish an absent skill from a tampered one

`get_skill_result`/`getSkillResult` runs the same retrieval as `get_skill`/`getSkill`, but reports why instead of collapsing to `None`/`null`. Use it to fail closed on suspected tampering while tolerating a merely absent skill.

<table>
  <thead>
    <tr>
      <th>Reason</th>
      <th>Meaning</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>`ok`</td>
      <td>A verified skill was returned.</td>
    </tr>

    <tr>
      <td>`absent`</td>
      <td>The store holds nothing under that key. It is either not configured, not yet delivered, or revoked. A pinned version the store does not yet hold also lands here, which is the ordinary rollout-skew case.</td>
    </tr>

    <tr>
      <td>`wrong_version`</td>
      <td>A version was pinned and the store answered with a different one, which means the answer was withheld. This means a store is misbehaving, usually a custom store adapter, rather than a version not having arrived yet.</td>
    </tr>

    <tr>
      <td>`integrity_failure`</td>
      <td>Content was delivered and failed verification, which means it was withheld. Treat this one as a hard stop rather than continuing in a degraded state.</td>
    </tr>

    <tr>
      <td>`store_unavailable`</td>
      <td>The store could not answer at all due to an outage.</td>
    </tr>
  </tbody>
</table>

`.detail` is human-readable and safe to log. It does not contain skill content or a filesystem path. There is no batch form of `get_skill_result`/`getSkillResult`. Call it per key where the outcome matters.

## Security guidance

This section covers how to detect tampering, isolate privileges between the reconcile process and the agent, and account for platform and beta-specific limitations when you materialize skills onto disk.

### Integrity verification and the audit log line

Every withheld skill emits one structured `ERROR` log record intended for security information and event management (SIEM) ingestion, regardless of your telemetry configuration.

In Python, it is on the logger `launchdarkly_ai_server.skills_core`.
In Node.js, it is one `console.error` line prefixed `[LaunchDarkly]`.

While agent skills is experimental, the event name `ld.skills.integrity_failure`, its fields, and its `reason_code` values may change in a minor release. Any such change gets a changelog entry under **Experimental**. If you alert on this record, review the changelog before you upgrade.

Each record's `reason_code` field holds one of the following values:

<table>
  <thead>
    <tr>
      <th>reason\_code</th>
      <th>Meaning</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>`hash_mismatch`</td>
      <td>The downloaded content's SHA-256 hash didn't match the hash LaunchDarkly delivered.</td>
    </tr>

    <tr>
      <td>`invalid_key`</td>
      <td>A key in the delivered response fails format validation. It is limited to 256 characters. The key can contain lowercase letters, numerals, or hyphens.</td>
    </tr>

    <tr>
      <td>`invalid_version`</td>
      <td>The version number associated with a skill was a non-integer, pointing to a problem with the skill provider.</td>
    </tr>

    <tr>
      <td>`key_mismatch`</td>
      <td>The store returned a payload whose embedded key doesn't match the key that was requested.</td>
    </tr>

    <tr>
      <td>`missing_content`</td>
      <td>The store entry has no content.</td>
    </tr>

    <tr>
      <td>`missing_content_hash`</td>
      <td>The store entry has content but no hash to verify it against.</td>
    </tr>

    <tr>
      <td>`not_an_object`</td>
      <td>The raw store entry is not shaped as a valid object.</td>
    </tr>

    <tr>
      <td>`not_utf8`</td>
      <td>The content could not be encoded as UTF-8.</td>
    </tr>

    <tr>
      <td>`over_size_cap`</td>
      <td>The content exceeds the SDK's size limit and was rejected.</td>
    </tr>

    <tr>
      <td>`version_mismatch`</td>
      <td>The store returned a version other than the one pinned by the reference.</td>
    </tr>
  </tbody>
</table>

Here's an example of what the log record looks like:

```text lines wrap theme={null}
ld.skills.integrity_failure {"action":"withheld","event":"ld.skills.integrity_failure","expected_hash":"0000…0000","language":"python","observed_hash":"5fc8…6ec0","reason":"content hash mismatch","reason_code":"hash_mismatch","skill_key":"pdf-extraction","version":2}
```

The `hash_mismatch`, `over_size_cap`, and `not_utf8` codes are the shapes of active tampering and warrant a page. The other reason codes indicate a malformed, truncated, or misbehaving payload. Skill content, filesystem paths, and credentials never appear in the record.

### Privilege separation

Run the reconcile (`write_skills`/`writeSkills`) as a different identity than the agent. A `SKILL.md` is agent instructions, which means an agent that can write its own skills directory can rewrite its own instructions, and write access to the manifest is worse because it controls what the next reconcile may delete.

The SDK sets skill files and the manifest to mode `0644` and skill directories to `0755`, with no execute bit, but those modes provide protection only if the two identities differ.

Write access to any directory above the managed root is write access to the root by another route, because it permits renaming the root aside and leaving a symlink in its place. Check each directory above the managed root, all the way up to `/`, and confirm the agent's identity can write to none of them.

### Platform guarantee differences

Python on POSIX and Node.js on Linux close the swap window. The managed root is opened once per reconcile and held for the duration of the call. This means a directory swapped for a symlink after its checks cannot redirect a write or a delete. Python on Windows and Node.js on macOS and Windows fall back to a per-component check immediately before each filesystem operation. This is a narrow check-then-use race, and an attacker with write permission on the managed root or its ancestors can win it. Windows is not a tested platform for materialization in this release.

### Beta caveats

Some limitations for the beta version include:

* No payload signing. Delivery is TLS-only. The content hash establishes self-consistency, not origin authenticity.
* No LaunchDarkly telemetry is emitted for skills. Customer-visible detection is the log record described above.
* Do not use view-scoped SDK keys for skills. Use an environment-level SDK key.
* An account without access to agent skills receives no skill content rather than an error status. The FDv2 endpoints themselves are available to every account.

## Troubleshooting

This table includes possible errors and their solutions:

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Likely cause</th>
      <th>Resolution</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>Store reports a fatal error naming HTTP 403</td>
      <td>The credential is not the environment's server-side SDK key, or a proxy between your application and LaunchDarkly is refusing the request. The FDv2 endpoints that skills use are available to every account. This is not an account-level setting.</td>
      <td>Confirm you are using the environment's server-side SDK key, and check any proxy in the request path. If neither explains it, contact LaunchDarkly support.</td>
    </tr>

    <tr>
      <td>The store starts and reports no error, but every skill is absent</td>
      <td>Your account does not have access to agent skills. LaunchDarkly leaves the skill payload out of delivery rather than returning an error status.</td>
      <td>Contact LaunchDarkly support or your account team to confirm your account has access to agent skills.</td>
    </tr>

    <tr>
      <td>Store reports a fatal error naming HTTP 422</td>
      <td>The SDK key is view-scoped.</td>
      <td>View-scoped SDK keys don't work with skills. Use an environment-level SDK key.</td>
    </tr>

    <tr>
      <td>HTTP 401 at start</td>
      <td>Wrong credential.</td>
      <td>Use the environment's server-side SDK key.</td>
    </tr>

    <tr>
      <td>Constructor raises or throws about a mobile key or client-side ID</td>
      <td>Non-server-side credential.</td>
      <td>Skills are server-side only.</td>
    </tr>

    <tr>
      <td>HTTP 404, reported as fatal</td>
      <td>Wrong `base_uri`/`stream_uri`, or the instance does not serve the FDv2 endpoints.</td>
      <td>Check both URIs. Relay Proxy does not serve these endpoints.</td>
    </tr>

    <tr>
      <td>A fatal error naming a 3xx redirect</td>
      <td>A proxy or private instance is redirecting the FDv2 endpoint.</td>
      <td>Redirects are never followed, which means your SDK key is never forwarded to another host. Check the URI and any proxy in between.</td>
    </tr>

    <tr>
      <td>Constructor rejects the URI</td>
      <td>A plain `http://` `base_uri` or `stream_uri`.</td>
      <td>Both must be `https://`. Loopback hosts are the only exception, for local test doubles.</td>
    </tr>

    <tr>
      <td>`get_skill`/`getSkill` immediately after `start()` returns nothing</td>
      <td>The first payload has not arrived.</td>
      <td>Call `wait_for_skills`/`waitForSkills` before the first retrieval, and check its return value.</td>
    </tr>

    <tr>
      <td>`report.ok` is `false` and the error says the retrieval was unavailable, with nothing written</td>
      <td>The reconcile ran before delivery answered.</td>
      <td>This prevents an empty store from reading as "everything was revoked." Gate the reconcile on `wait_for_skills`/`waitForSkills`.</td>
    </tr>

    <tr>
      <td>A `ValueError` or thrown error from `write_skills`/`writeSkills` naming the root</td>
      <td>The root's parent is missing, the root is a file, or the root is a symlink.</td>
      <td>Create the parent directory, and point at a real directory.</td>
    </tr>

    <tr>
      <td>A skill is reported as `error` with "not managed" wording and left untouched</td>
      <td>A file you placed at a managed path collides with a skill, and its bytes differ from the delivered content.</td>
      <td>Move or remove your file. The SDK never overwrites unmanaged files. A file that already matches the delivered bytes exactly is adopted instead and reported `skipped_current`.</td>
    </tr>

    <tr>
      <td>A revoked skill is still on disk</td>
      <td>A one-shot `write_skills`/`writeSkills` call with no watcher, poll mode, or an explicit skill list.</td>
      <td>Use `watch_skills`/`watchSkills` with `"*"` and streaming for seconds-latency pruning. With an explicit list, re-run `write_skills`/`writeSkills` with a fresh list to remove a skill unpinned from the variation. A skill deleted in LaunchDarkly that the variation still references stays on the list, is reported as an `error` action, and is kept. Use `"*"` when revocation must reach the disk. Otherwise, pruning happens at the next reconcile.</td>
    </tr>

    <tr>
      <td>`start()` raises on a store that was working earlier</td>
      <td>The store was closed. `close()` is final.</td>
      <td>Construct a new store. A closed one still answers from cached content but cannot resume delivery.</td>
    </tr>
  </tbody>
</table>
