Agent skills

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 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:

Install the SDK

Agent skills ships inside the existing AI SDK server packages. You do not install a separate package:

pip install launchdarkly-ai-server

Agent skill integration patterns

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

PatternUse casesSDK outputsAPI
Filesystem agentsClaude Agent SDK, Codex, or anything that discovers a skills/ directorySkill files written to disk before the agent starts, kept current with LaunchDarklywrite_skills/writeSkills, optionally watch_skills/watchSkills
Dynamic loadersCustom harnesses, LangChain-style skill-loading toolsVerified skill bytes on demand at runtimeget_skill/getSkill and related functions
Provider-uploaded referencesAnthropic’s container.skills, OpenAI skill referencesThe list of pinned skill references on the resolved variation, to pass through in the model call. No content is fetched.skill_refs/skillRefs only. Needs no delivery setup.

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.

AI SDKs

This feature is available for the following AI SDKs:

Python AI

Set up delivery

Filesystem and dynamic-loader patterns need skill content, which arrives through a skill store that you create and pass to init_client. The production store is FDv2SkillStore. Start it, wait for the first payload, then initialize the client with it:

Python AI SDK
import os
from launchdarkly_ai_server import FDv2SkillStore, init_client
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")
await init_client(options={"skillStore": store})
# ... 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 AI SDK
import hashlib
from launchdarkly_ai_server import InMemorySkillStore, init_client
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(),
})
await init_client(options={"skillStore": 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 AI SDK
from launchdarkly_ai_server import inspect_config, skill_refs
info = await inspect_config("doc-agent", {"kind": "user", "key": "user-123"})
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 AI SDK
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 AI SDK
from launchdarkly_ai_server 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 AI SDK
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.

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 AI SDK
from pathlib import Path
from launchdarkly_ai_server import init_client, inspect_config, skill_refs, write_skills
await init_client(options={"skillStore": store})
info = await inspect_config("doc-agent", context)
Path(".claude").mkdir(exist_ok=True) # the root's parent must already exist
if info["meta"] 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, a revoked skill, such as one removed from a variation or deleted, has its SKILL.md pruned from the managed root within seconds instead of at the next restart.

The following example keeps the root synced with a watcher:

Python AI SDK
from launchdarkly_ai_server import watch_skills
watcher = None
if info["meta"] is None:
log.error("config unavailable; skipping reconcile")
else:
report, watcher = await watch_skills(skill_refs(info["config"]), ".claude/skills")
try:
run_agent()
finally:
if watcher is not None:
watcher.close()
store.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.

Node.js (server-side) AI

Set up delivery

Filesystem and dynamic-loader patterns need skill content, which arrives through a skill store that you create and pass to initClient. The production store is FDv2SkillStore. Start it, wait for the first payload, then initialize the client with it:

Node.js AI SDK
import { FDv2SkillStore, initClient } from '@launchdarkly/ai-server';
const store = new FDv2SkillStore(process.env.LD_SDK_KEY!).start();
if (!(await store.waitForSkills(10_000))) {
console.warn(`skill delivery has not answered: ${store.failed ?? 'still waiting'}`);
}
await initClient({ skillStore: store });
// ... 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:

Node.js AI SDK
import { InMemorySkillStore, initClient } from '@launchdarkly/ai-server';
const store = new InMemorySkillStore();
store.put({
key: 'pdf-extraction',
version: 2,
content: skillMd,
contentHash: computedSha256Hex,
});
await initClient({ skillStore: 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:

Node.js AI SDK
import { inspectConfig, skillRefs } from '@launchdarkly/ai-server';
const info = await inspectConfig('doc-agent', { kind: 'user', key: 'user-123' });
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

Node.js AI SDK
import { getSkill, getSkills, allSkills, getSkillResult } from '@launchdarkly/ai-server';
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.

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:

Node.js AI SDK
import { initClient, inspectConfig, skillRefs, writeSkills } from '@launchdarkly/ai-server';
await initClient({ skillStore: store });
const info = await inspectConfig('doc-agent', context);
if (info.meta === null) {
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, a revoked skill’s SKILL.md is pruned from the managed root within seconds instead of at the next restart.

The following example keeps the root synced with a watcher:

Node.js AI SDK
import { watchSkills } from '@launchdarkly/ai-server';
const { report, watcher } = await watchSkills(skillRefs(info.config), '.claude/skills');
try {
// run the agent
} finally {
await watcher.close();
await store.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.

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:

PythonNode.jsDescriptionDefault value
modemode”stream” or “poll”.“stream”
base_uribaseUriMust 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.https://sdk.launchdarkly.com
stream_uristreamUriMust 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.https://stream.launchdarkly.com
poll_interval (seconds)pollIntervalMsPoll mode only.30 seconds
initial_backoff, max_backoff (seconds)initialBackoffMs, maxBackoffMsReconnect backoff. max_backoff caps every delay between retries, including one the server asks for with Retry-After.1s / 30s
max_consecutive_failuresmaxConsecutiveFailuresAfter this many failures, the transport stops retrying, logs an error, and keeps serving last known good content. failed reports it.10
read_timeout (seconds)readTimeoutMsThe 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.10s in poll mode, 300s in stream mode
Python and Node.js use different time units

Python takes seconds for store options and Node.js takes milliseconds. The same is true of the watch_skills/watchSkills debounce. debounce takes 0.5s in Python, and debounceMs takes 500ms in Node.js.

The timeout option on write_skills/writeSkills is the exception. It is in seconds in both languages, and in Node.js it sits in the same options object as debounceMs.

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:

OptionDefault valueDescription
pruneTrueRemove previously managed skills that are no longer in the requested set. This is also how revocation reaches disk.
timeout10 seconds, both languagesTotal budget for the call, including retrieval.
on_unavailable/onUnavailable”keep""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.

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.

ReasonMeaning
okA verified skill was returned.
absentThe 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.
wrong_versionA 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.
integrity_failureContent was delivered and failed verification, which means it was withheld. Treat this one as a hard stop rather than continuing in a degraded state.
store_unavailableThe store could not answer at all due to an outage.

.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].

The event name ld.skills.integrity_failure is a stability commitment. It and its field names will not change outside a major release, which means you can alert on it directly.

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

reason_codeMeaning
hash_mismatchThe downloaded content’s SHA-256 hash didn’t match the hash LaunchDarkly delivered.
invalid_keyA key in the delivered response fails format validation. It is limited to 256 characters. The key can contain lowercase letters, numerals, or hyphens.
invalid_versionThe version number associated with a skill was a non-integer, pointing to a problem with the skill provider.
key_mismatchThe store returned a payload whose embedded key doesn’t match the key that was requested.
missing_contentThe store entry has no content.
missing_content_hashThe store entry has content but no hash to verify it against.
not_an_objectThe raw store entry is not shaped as a valid object.
not_utf8The content could not be encoded as UTF-8.
over_size_capThe content exceeds the SDK’s size limit and was rejected.
version_mismatchThe store returned a version other than the one pinned by the reference.

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

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.

Troubleshooting

This table includes possible errors and their solutions:

SymptomLikely causeResolution
Store reports a fatal error naming HTTP 403FDv2 delivery is not enabled for your account.Contact LaunchDarkly support or your account team to enable FDv2.
Store reports a fatal error naming HTTP 422The SDK key is view-scoped.View-scoped SDK keys don’t work with skills. Use an environment-level SDK key.
HTTP 401 at startWrong credential.Use the environment’s server-side SDK key.
Constructor raises or throws about a mobile key or client-side IDNon-server-side credential.Skills are server-side only.
HTTP 404, reported as fatalWrong base_uri/stream_uri, or the instance does not serve the FDv2 endpoints.Check both URIs. Relay Proxy does not serve these endpoints.
A fatal error naming a 3xx redirectA proxy or private instance is redirecting the FDv2 endpoint.Redirects are never followed, which means your SDK key is never forwarded to another host. Check the URI and any proxy in between.
Constructor rejects the URIA plain http:// base_uri or stream_uri.Both must be https://. Loopback hosts are the only exception, for local test doubles.
get_skill/getSkill immediately after start() returns nothingThe first payload has not arrived.Call wait_for_skills/waitForSkills before the first retrieval, and check its return value.
report.ok is false and the error says the retrieval was unavailable, with nothing writtenThe reconcile ran before delivery answered.This prevents an empty store from reading as “everything was revoked.” Gate the reconcile on wait_for_skills/waitForSkills.
A ValueError or thrown error from write_skills/writeSkills naming the rootThe root’s parent is missing, the root is a file, or the root is a symlink.Create the parent directory, and point at a real directory.
A skill is reported as error with “not managed” wording and left untouchedA file you placed at a managed path collides with a skill, and its bytes differ from the delivered content.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.
A revoked skill is still on diskA one-shot write_skills/writeSkills call with no watcher, or poll mode.Use watch_skills/watchSkills with streaming for seconds-latency pruning. Otherwise, pruning happens at the next reconcile.
start() raises on a store that was working earlierThe store was closed. close() is final.Construct a new store. A closed one still answers from cached content but cannot resume delivery.