Migrate to AgentControl
You're using a skill that will guide you through migrating an application from hardcoded LLM prompts to a full LaunchDarkly AgentControl implementation. Your job is to run the migration in five stages, stopping at each stage for the user to confirm:
- Audit the code — read-only scan that produces a structured list of everything hardcoded (prompt, model, parameters, tools, app-scoped knobs).
- Wrap the call — install the SDK, create the config in LaunchDarkly with a fallback that mirrors the hardcoded values, and rewrite the call site to fetch the config fresh on every request.
- Move the tools — extract each tool's JSON schema, attach it to the config, and swap every call site that references the old tool list.
- Add tracking — wire the per-request tracker (duration, tokens, success/error) around the provider call.
- Attach evaluators — either offline evals via the Playground + Datasets, or online judges that score sampled traffic automatically.
⚠️ Three first-run failure modes to avoid.
- Tracker in the wrong scope. For an agent with a loop, mint
create_tracker() once per user turn in a setup_run entry node — not inside call_model. Per-iteration factory calls produce N runIds and trip the at-most-once guards. See agent-mode-frameworks.md § Custom StateGraph.
load_chat_model wrapper reuse. Templates like langchain-ai/react-agent ship a load_chat_model(f"{provider}/{name}") helper that wraps init_chat_model(...) and silently drops every variation parameter. Delete it (don't just avoid using it) and replace call sites with create_langchain_model(ai_config).
- Fallthrough not flipped after
/configs-create. A freshly-created config's fallthrough points at an auto-generated disabled variation, so the SDK returns enabled=False until /configs-targeting runs. Flip it before Stage 2 verification.
Coverage — which shapes are well-trodden vs require extrapolation
The skill is optimized for Python and Node.js / TypeScript; other languages are install-only. Within Python and Node the coverage tiers are:
| Shape |
Python |
Node.js |
Reference |
| One-shot completion (direct OpenAI / Anthropic / Bedrock / Gemini call) |
✅ Worked example |
✅ Worked example |
before-after-examples.md, per-provider docs in built-in-metrics/references/ |
Chat loop via managed runner (ManagedModel) |
✅ Tier 1 pattern |
✅ Tier 1 pattern |
built-in-metrics SKILL.md |
| LangChain single-call |
✅ Worked example |
✅ Worked example |
langchain-tracking.md |
LangGraph prebuilt agent (Python langchain.agents.create_agent, Node createReactAgent) |
✅ Worked example |
✅ Worked example |
agent-mode-frameworks.md § LangGraph |
LangGraph custom StateGraph with run-scoped tracker (setup_run + call_model + finalize) |
✅ Deep worked example |
⚠️ Mentioned — translate from Python |
agent-mode-frameworks.md § Custom StateGraph |
CrewAI Agent |
✅ Worked example |
— (not a Node framework) |
agent-mode-frameworks.md § CrewAI |
Strands Agent |
✅ Worked example |
⚠️ BedrockModel + OpenAIModel only (no Anthropic) |
agent-mode-frameworks.md § Strands |
| Custom ReAct loop (hand-rolled, any framework or none) |
✅ Worked example |
⚠️ Apply framework-agnostic invariants; translate from Python |
agent-mode-frameworks.md § Custom ReAct loop |
Vercel AI SDK (generateText / streamText) |
— (not a Python framework) |
⚠️ Provider package exists; no worked example in skill |
built-in-metrics provider-package matrix |
| Streaming (SSE / WebSocket) |
⚠️ Delegated to built-in-metrics streaming doc |
⚠️ Same — use trackStreamMetricsOf + manual TTFT |
streaming-tracking.md |
| Multi-agent graph (supervisor + workers) |
⚠️ Out of main scope; see reference |
⚠️ Out of main scope; see reference |
agent-graph-reference.md |
| Non-LangGraph agent frameworks (Pydantic AI, DSPy, AutoGen, Haystack, LlamaIndex agents, Semantic Kernel) |
⚠️ Apply the three invariants; no framework-specific example |
⚠️ Same |
agent-mode-frameworks.md § Framework-agnostic invariants |
| Go, Ruby, .NET |
ℹ️ Install commands only |
ℹ️ Install commands only |
phase-1-analysis-checklist.md § SDK routing table |
Reading the key: ✅ = follow the skill verbatim; ⚠️ = the architecture applies but you'll translate idioms or cross-reference another skill; ℹ️ = skill doesn't go past the install step.
If the target app is in the ⚠️ column, start by reading agent-mode-frameworks.md § Framework-agnostic invariants — those three rules (one agent_config per turn, one tracker per turn, at-most-once methods fire once at turn end) apply regardless of framework, and every code snippet in this skill is an instantiation of them. Translate the Python example's shape onto the target framework's primitives.
Prerequisites
This skill requires the remotely hosted LaunchDarkly MCP server to be configured in your environment, and an application that already calls an LLM provider with hardcoded model, prompt, and parameter values.
Required environment:
LD_SDK_KEY — server-side SDK key (starts with sdk-) from the target LaunchDarkly project
MCP tools used directly by this skill: none — every LaunchDarkly write happens in a focused sibling skill.
Check the SDK CHANGELOG before applying any pattern. The API surface described throughout this skill targets the SDK behavior at the time of the skill's last update; SDK releases can rename, remove, or split methods after that. Before you start, fetch the latest CHANGELOG for the SDK(s) you'll target and skim for anything that contradicts the pattern you're about to apply:
If a CHANGELOG entry post-dates this skill and changes an API you're about to use, the CHANGELOG wins — and the skill should be updated.
Hand-off model. This skill does not auto-invoke other skills. At each stage that needs a LaunchDarkly write, this skill prepares the inputs (config key, mode, model, prompt, tool schemas, judge keys) and then tells the user to run the next slash-command themselves. After the user finishes that sibling skill, return to the next step here. Treat the "Delegate" lines below as next-step instructions, not auto-handoffs.
Sibling skills the user runs at each stage:
projects — pre-Stage 2, only if no project exists yet
configs-create — Stage 2 (creates the config and first variation)
tools — Stage 3 (creates tool definitions and attaches them)
configs-targeting — between Stage 2 and Stage 4 (promotes the new variation to fallthrough so the SDK actually serves it)
online-evals — Stage 5 (attaches judges, creates custom judges)
Core Principles
- Inspect before you mutate. Every stage begins with a read-only audit. Do not touch code until Step 1 is confirmed by the user.
- Replace config, not business logic. The SDK call is a drop-in for the place where the model, parameters, and prompt are defined — not for the provider call itself. OpenAI/Anthropic/Bedrock calls stay where they are.
- Fallback mirrors current behavior. The fallback passed to
completion_config / agent_config must preserve the hardcoded values you removed, so the app is unchanged if LaunchDarkly is unreachable.
- Stages are ordered. Wrap before you add tools. Add tools before you track. Track before you add evals. Skipping ahead produces configs without traffic, metrics without context, and judges with nothing to score.
- Hand off to focused skills, manually. Each stage that needs a LaunchDarkly write tells the user to run a sibling slash-command (
/configs-create, /tools, /configs-targeting, /online-evals) and waits for them to come back. This skill does not auto-invoke other skills.
Workflow
Minimum viable migration
Stages 1–4 (audit, wrap, tools, tracker) are independently shippable. A migration that stops after Stage 4 is complete, production-ready, and delivers the core value — externalized prompts and model config, targeting, variation A/B testing, and Monitoring-tab metrics. Stage 5 (evaluators) is a quality-of-life addition, not a gate. Do not block a Stage-4 rollout on evaluators; ship the run-scoped tracker path, verify metrics flow, then come back for Stage 5 when the team has time to curate a dataset.
That said, do not skip Stage 4. A migration without the tracker gives you externalized prompts but no visibility, which is most of the payoff left on the floor.
Step 1: Audit the codebase (Stage 1)
This is the first stage. It is read-only — no code writes, no LaunchDarkly resources created. The goal is to scan the repo and produce a structured manifest of every hardcoded value that needs to move, then hand the manifest back to the user for confirmation before any code is touched in Stage 2.
Use phase-1-analysis-checklist.md to scan:
- Language and package manager — Python (pip/poetry/uv), TypeScript/JavaScript (npm/pnpm/yarn), Go, Ruby, .NET
- LLM provider — OpenAI, Anthropic, Bedrock, Gemini, LangChain, LangGraph, CrewAI, Strands
- Existing LaunchDarkly usage — any pre-existing
LDClient or ldclient initialization to reuse
- Hardcoded model configs — model name string literals, temperature / max_tokens / top_p, system prompts, instruction strings
- Template placeholders in prompts —
.format() calls, f-strings in prompt constants, JS/TS template literals, %(var)s, hand-rolled str.replace("__VAR__", ...). Flag each placeholder name and its runtime-value source; all get rewritten to Mustache {{ variable }} in Stage 2.
- Externalized prompt files — scan YAML / JSON / TOML / Markdown /
.prompt / .j2 files and prompt-template registries (langchain.hub.pull(...), LangSmith client.pull_prompt(...)) for prompts loaded at runtime. Common shapes: CrewAI agents.yaml / tasks.yaml, LangChain Promptfiles, k8s ConfigMap overlays, Pydantic Settings classes with prompt_* fields. Same Mustache rewrite (sub-step 5 of Stage 2) applies if the placeholder syntax differs. See phase-1-analysis-checklist.md § 4.
- Hardcoded app-scoped knobs — search-result limits, retry budgets, tool-timeout overrides, feature toggles, any config-dataclass field that isn't a prompt or model parameter but still governs agent behavior. These belong in
model.custom on the variation (not model.parameters, which is forwarded to the provider SDK and will crash on unknown kwargs).
- Mode decision — completion mode (chat messages array) or agent mode (single instructions string). Completion mode is the default and the only mode that supports judges attached in the UI.
For each hardcoded target the audit finds, record:
- File path and line range
- Current value (model name, full prompt text, parameter dict)
- Target config field (
model.name, model.parameters.temperature, messages[].content, instructions)
- Whether the surrounding call uses function calling / tools (drives Stage 3)
- Whether the surrounding call has retry logic (affects where Stage 4 tracker calls go)
This manifest is the contract for the next four stages.
Stage 1 output (return to user as a structured summary):
Language: Python 3.12
Package manager: uv
LLM provider: OpenAI
Existing LD SDK: none
Target mode: completion
Hardcoded targets:
- src/chat.py:42 model="gpt-4o"
- src/chat.py:43 temperature=0.7, max_tokens=2000
- src/chat.py:45 system="You are a helpful assistant..."
Externalized prompt files: none (or e.g. "prompts/agents.yaml — CrewAI role/goal/backstory")
Prompt-template registries: none (or e.g. langchain.hub.pull("rlm/rag-prompt") at app.py:14)
Coverage totals: 3 hardcoded code targets · 0 externalized prompt files · 0 registry pulls
Proposed plan: single config key `chat-assistant`, mirror fallback, Stage 3 (tools) skipped (no function calling), Stage 4 (tracking) inline, Stage 5 (evals) attach built-in accuracy judge.
STOP. Present this summary, state the coverage totals out loud (e.g. "I found N hardcoded code targets and M externalized prompt files — does that match what you expected?"), and wait for the user to reply with one of four explicit forms:
confirm — proceed to Stage 2.
add: <files or paths> — re-run the audit with the new locations and present an updated summary.
fix: <correction> — update a target in the list (provider, mode, prompt content, etc.) and ask again.
stop — pause the migration here.
Do not interpret any other word — including skip, next, go, ok, proceed — as confirmation; ask the user to pick one of the four forms. This is the most important checkpoint in the workflow — if the audit is wrong, every stage after this will be wrong. The user should cross-check the hardcoded-targets list against what they know is in the code before giving the go-ahead.
Step 2: Wrap the call in the AI SDK (Stage 2)
This is the first stage that writes code. It has nine sub-steps.
Delete any hand-rolled model / tool wrappers the audit flagged. Do this before installing the new SDK so the replacement lands in a repo without confusing fallback imports. The two shapes the Stage 1 audit should have surfaced:
load_chat_model(f"{provider}/{name}") or any init_chat_model(...) wrapper. Ships with langchain-ai/react-agent and many derivative repos. Delete the function and its module; the replacement is create_langchain_model(ai_config) (installed in the next sub-step). Leaving the wrapper in place means the next edit in this repo will import the familiar helper and silently drop variation parameters.
- Hand-rolled
resolve_tools / TOOL_REGISTRY / ALL_TOOLS helpers that hard-code a static tool list. Delete them; ldai_langchain.langchain_helper.build_structured_tools(ai_config, TOOL_REGISTRY_DICT) is the canonical replacement and gets wired in Stage 3. If you leave the hand-rolled version, both shapes will live side-by-side and the next contributor will pick the familiar one.
Commit the deletion separately from the SDK install if the repo's review process benefits from it — otherwise bundle with sub-step 2.
Install the AI SDK. Detect the package manager from Step 1, then install:
- Python:
launchdarkly-server-sdk + launchdarkly-server-sdk-ai>=0.20.0
- Node.js/TypeScript:
@launchdarkly/node-server-sdk + @launchdarkly/server-sdk-ai@^0.20.0
- Go:
github.com/launchdarkly/go-server-sdk/v7 + github.com/launchdarkly/go-server-sdk/ldai
Tier-2 provider packages (install in Stage 4, only if you're using the matching provider):
- OpenAI:
launchdarkly-server-sdk-ai-openai>=0.4.0 (Python) / @launchdarkly/server-sdk-ai-openai@^0.5.5 (Node)
- LangChain / LangGraph:
launchdarkly-server-sdk-ai-langchain>=0.5.0 (Python) / @launchdarkly/server-sdk-ai-langchain@^0.5.5 (Node)
- Vercel AI SDK (Node only):
@launchdarkly/server-sdk-ai-vercel@^0.5.5
- Anthropic, Gemini, Bedrock — no provider package published; use Tier-3 custom extractor (see
built-in-metrics)
Initialize LDAIClient once at startup. Reuse any existing LDClient — do not create a second base client. Place the initialization in the same module that owns existing app config.
Python:
import os
import ldclient
from ldclient.config import Config
from ldai.client import LDAIClient
# Order matters: ldclient.get() raises if called before ldclient.set_config().
# The set_config call is what initializes the singleton; .get() just returns it.
sdk_key = os.environ.get("LD_SDK_KEY")
if sdk_key:
ldclient.set_config(Config(sdk_key))
else:
# Missing key: init in offline mode so the app still starts and the fallback
# path runs on every call. Never raise at import time for a missing env var —
# that turns a config gap into a boot failure.
import logging
logging.getLogger(__name__).warning(
"LD_SDK_KEY not set; configs will use fallback values only."
)
ldclient.set_config(Config("", offline=True))
ai_client = LDAIClient(ldclient.get())
Node.js/TypeScript:
import { init } from '@launchdarkly/node-server-sdk';
import { initAi } from '@launchdarkly/server-sdk-ai';
// The Node SDK does not have an explicit offline mode — a missing or invalid
// key fails fast during waitForInitialization, and every agent_config /
// completion_config call returns the fallback. Log a warning; do not throw.
if (!process.env.LD_SDK_KEY) {
console.warn('LD_SDK_KEY not set; configs will use fallback values only.');
}
const ldClient = init(process.env.LD_SDK_KEY ?? 'sdk-offline');
await ldClient.waitForInitialization({ timeout: 10 }).catch(() => {
// Swallow init failures in offline mode; fallback path runs.
});
const aiClient = initAi(ldClient);
Hand off to configs-create. Print the extracted model, prompt/instructions, parameters, and mode from the Stage 1 manifest, then tell the user: "Run /configs-create with these inputs, then come back here." Supply the config key you want the code to call (e.g. chat-assistant). Do not attempt to auto-invoke the sibling skill — wait for the user to finish it before continuing.
After configs-create finishes, the user must also run /configs-targeting to promote the new variation to fallthrough. A freshly created variation returns enabled=False to every consumer until targeting is updated. Skip this and Stage 2 verification (sub-step 9 below) will silently take the fallback path on every request.
Rewrite template placeholders to Mustache syntax. If the hardcoded prompt interpolates runtime values with Python .format(), f-strings, JS template literals, or any other non-Mustache syntax (e.g. {system_time}, ${userName}, %(topic)s), rewrite every placeholder to {{ variable }} Mustache form. Do this in both the file you're about to send to /configs-create and the fallback string you'll write in sub-step 6. The AI SDK interpolates variables through a Mustache renderer on the LD-served path and the fallback path using the fourth-argument variables dict to completion_config(...) / completionConfig(...). Leaving a Python-style {system_time} literal in the fallback ships a silent regression when LaunchDarkly is unreachable — the renderer won't match the single-brace form and the literal {system_time} goes to the provider as part of the prompt.
Before:
SYSTEM_PROMPT = "You are a helpful assistant. The time is {system_time}."
prompt = SYSTEM_PROMPT.format(system_time=datetime.now().isoformat())
After (in source):
SYSTEM_PROMPT = "You are a helpful assistant. The time is {{ system_time }}."
# .format() is removed at the call site — the SDK interpolates via `variables`
config = ai_client.completion_config(
CONFIG_KEY,
context,
fallback,
variables={"system_time": datetime.now().isoformat()},
)
Common shapes to rewrite:
- Python
"{var}" / "{var!s}" / "%(var)s" → "{{ var }}"
- JS/TS
`${var}` template literals inside prompt strings → "{{ var }}"
- Any hand-rolled
str.replace("__VAR__", value) scheme → "{{ var }}"
See fallback-defaults-pattern.md § Template placeholders for the fallback-specific variant.
Build the fallback. Mirror the hardcoded values you extracted. Use AICompletionConfigDefault / AIAgentConfigDefault in Python, plain object literals in Node. See fallback-defaults-pattern.md for inline, file-backed, and bootstrap-generated patterns.
Python fallback (completion mode):
from ldai.client import AICompletionConfigDefault, ModelConfig, ProviderConfig, LDMessage
fallback = AICompletionConfigDefault(
enabled=True,
model=ModelConfig(name="gpt-4o", parameters={"temperature": 0.7, "max_tokens": 2000}),
provider=ProviderConfig(name="openai"),
messages=[LDMessage(role="system", content="You are a helpful assistant...")],
)
Replace the hardcoded call site. Swap the hardcoded model/prompt/params for a completion_config / completionConfig (or agent_config / agentConfig) call, then read the returned fields into the existing provider call. Keep the provider call intact.
Python — before:
response = openai_client.chat.completions.create(
model="gpt-4o",
temperature=0.7,
max_tokens=2000,
messages=[
{"role": "system", "content": "You are a helpful assistant..."},
{"role": "user", "content": user_input},
],
)
Python — after:
context = Context.builder(user_id).set("email", user.email).build()
config = ai_client.completion_config("chat-assistant", context, fallback)
if not config.enabled:
return disabled_response()
params = config.model.parameters or {}
response = openai_client.chat.completions.create(
model=config.model.name,
temperature=params.get("temperature"),
max_tokens=params.get("max_tokens"),
messages=[m.to_dict() for m in (config.messages or [])] + [
{"role": "user", "content": user_input},
],
)
Python — after (agent mode) — for LangGraph, CrewAI, or any framework that takes a goal/instructions string:
context = Context.builder(user_id).kind("user").build()
config = ai_client.agent_config("support-agent", context, FALLBACK)
if not config.enabled:
return disabled_response()
# config is a single AIAgentConfig object — NOT a (config, tracker) tuple.
# Obtain the tracker once per execution via the factory: tracker = config.create_tracker()
model_name = f"{config.provider.name}/{config.model.name}"
instructions = config.instructions
params = config.model.parameters or {}
# Pass model_name + instructions into your framework's agent constructor.
# Example: LangGraph prebuilt agent (Python — `from langchain.agents import create_agent`;
# this replaces `langgraph.prebuilt.create_react_agent`, deprecated in LangGraph 1.0
# and removed in 2.0. Same return shape; `prompt=` was renamed to `system_prompt=`.)
# agent = create_agent(
# create_langchain_model(config), # forwards every variation parameter
# TOOLS, # Stage 3 will replace this with a config.tools loader
# system_prompt=instructions,
# )
See before-after-examples.md for full Python OpenAI, Node Anthropic, and LangGraph agent-mode paired snippets.
Check config.enabled. If it returns False, handle the disabled path without crashing and without calling the provider. The check is required — not optional.
Verify. Run the app with a valid LD_SDK_KEY; confirm the call succeeds and the response matches pre-migration output. Then temporarily set LD_SDK_KEY=sdk-invalid (or unset it) and confirm the fallback path runs without error. Both paths must work before moving to Stage 3.
Delegate: configs-create (sub-step 4).
Step 3: Move tools into the config (Stage 3)
Skip this step if the audited app has no function calling / tools. Otherwise:
Enumerate the tools currently registered. Common shapes to look for:
openai.chat.completions.create(tools=[...]) — OpenAI direct
anthropic.messages.create(tools=[...]) — Anthropic direct
create_agent(llm, tools=[...], system_prompt=...) — LangGraph prebuilt (Python, langchain.agents; replaces deprecated langgraph.prebuilt.create_react_agent)
createReactAgent({ llm, tools: [...] }) — LangGraph.js prebuilt (Node, @langchain/langgraph/prebuilt)
Agent(tools=[...]) — CrewAI
Agent(tools=[...]) — Strands (Python @tool-decorated callables passed through the constructor; TS SDK uses Zod-schema tools)
- Custom
StateGraph — module-level TOOLS = [...] list referenced in both model.bind_tools(TOOLS) and ToolNode(TOOLS). This is the langchain-ai/react-agent template shape; the list is usually in a tools.py module. Grep for bind_tools( and ToolNode( together — they will point at the same list.
Record each tool's name, description, and JSON schema.
For LangChain/LangGraph tools defined with @tool, extract the schema via tool.args_schema.model_json_schema() (or the equivalent Pydantic model_json_schema() call). For plain async callables used as tools (common in custom StateGraph shapes), LangChain infers the schema from the function signature at bind time — extract it via StructuredTool.from_function(fn).args_schema.model_json_schema(). Do not hand-write the schema.
Hand off to tools. Print the extracted tool names, descriptions, and schemas, then tell the user: "Run /tools with these tools and the variation key, then come back here." The sibling skill creates tool definitions (create-ai-tool) and attaches them to the variation (update-ai-config-variation). Wait for the user to finish before proceeding to sub-step 3. Do not auto-invoke.
Replace the hardcoded tools array at the call site with a read from config.tools (or the SDK equivalent for your language). Load the actual implementation functions dynamically from the tool names — see agent-mode-frameworks.md for the dynamic-tool-factory pattern from the devrel agents tutorial.
For custom StateGraph shapes, you must update both call sites: .bind_tools(TOOLS) and ToolNode(TOOLS) must both read from the same config.tools-derived list. Forgetting one leaves the LLM seeing the new tools but the executor still running the old ones, or vice versa.
Verify. Run the app; confirm the tool flows still execute correctly. get-ai-config (via the delegate) confirms the tools are attached server-side.
Delegate: tools (sub-step 2).
Step 4: Instrument the tracker (Stage 4)
Delegate: built-in-metrics wires the per-request tracker.track_* calls (duration, tokens, success/error, feedback) around the provider call. Use custom-metrics alongside it if the app needs business metrics beyond the built-in agent ones. Note: do not confuse this with launchdarkly-metric-instrument, which is for ldClient.track() feature metrics — a different API. See sdk-ai-tracker-patterns.md for the full per-method Python + Node matrix that the delegate skill draws on.
Hand off: print the config key, variation key, provider, and whether the call is streaming, then tell the user: "Run /built-in-metrics with these inputs, then come back here." Do not auto-invoke. Return here for sub-step 5 (verify) once they're done.
Create the tracker. Obtain a per-execution tracker via the factory on the config returned in Stage 2: tracker = config.create_tracker() (Python) or const tracker = aiConfig.createTracker(); (Node). Call the factory once per user turn and reuse the returned tracker for every tracking call in that turn — each call mints a fresh runId that tags every event emitted from the turn so they can be correlated via exported events or downstream queries. (The Monitoring tab aggregates today; run-level grouping is a downstream concern — but the runId is also what the SDK's at-most-once guards are keyed on, so minting a new one mid-turn breaks the guard semantics regardless of where the events end up.)
Where to call the factory depends on the call shape:
- Completion mode / one-shot provider call: mint the tracker right after
completion_config(...) returns, in the same function that handles the request.
- Agent mode with a ReAct loop (LangGraph, LangChain, custom): mint the tracker in a dedicated
setup_run entry node that executes once before the loop, stash it on graph state, and read it from state in call_model / tool handlers / a terminal finalize node. Emitting track_duration / track_tokens / track_success inside the loop body will trip the at-most-once guards. See agent-mode-frameworks.md § Custom StateGraph (run-scoped architecture) for the full setup_run + call_model + finalize pattern.
- Managed runner (Tier 1): skip this step entirely.
ManagedModel mints the tracker internally per run() / invoke(). Move to sub-step 4 if that's what the app uses.
Pick a tier from the four-tier ladder. See sdk-ai-tracker-patterns.md § Tier decision table for the full table (chat loop → Tier 1; provider-package call → Tier 2; custom extractor → Tier 3; streaming/manual → Tier 4).
Wire the chosen tier. The delegate skill has full Python + Node examples for each tier plus per-provider files. A condensed Tier 2/3 example for reference — OpenAI via the provider package:
Python:
from ldai_openai import get_ai_metrics_from_response
import openai
client = openai.OpenAI()
tracker = config.create_tracker()
def call_openai():
return client.chat.completions.create(
model=config.model.name,
messages=[{"role": "system", "content": config.messages[0].content},
{"role": "user", "content": user_prompt}],
)
# Exceptions are tracked automatically — track_metrics_of catches
# exceptions, records tracker.track_error(), and re-raises. Wrap your
# own try/except only for local handling (logging, fallback).
response = tracker.track_metrics_of(get_ai_metrics_from_response, call_openai)
Node:
import { getAIMetricsFromResponse } from '@launchdarkly/server-sdk-ai-openai';
const tracker = aiConfig.createTracker();
// Exceptions are tracked automatically — trackMetricsOf catches
// exceptions, records tracker.trackError(), and re-throws.
const response = await tracker.trackMetricsOf(
getAIMetricsFromResponse,
() => openaiClient.chat.completions.create({
model: aiConfig.model!.name,
messages: [...aiConfig.messages, { role: 'user', content: userPrompt }],
}),
);
For Anthropic direct, Bedrock (no provider package), Gemini, and custom HTTP, write a small extractor returning LDAIMetrics — see the delegate skill's anthropic-tracking.md, bedrock-tracking.md, and gemini-tracking.md. LangChain single-node and LangGraph go through the launchdarkly-server-sdk-ai-langchain / @launchdarkly/server-sdk-ai-langchain provider package. Build the model with create_langchain_model(config) (Python) / createLangChainModel(config) (Node) — both forward all variation parameters — and track with get_ai_metrics_from_response / getAIMetricsFromResponse. See langchain-tracking.md.
Wire feedback tracking if the app has thumbs-up/down UI. Both SDKs expose trackFeedback with a {kind} argument.
Python:
from ldai.tracker import FeedbackKind
tracker.track_feedback({"kind": FeedbackKind.Positive})
Node:
import { LDFeedbackKind } from '@launchdarkly/server-sdk-ai';
tracker.trackFeedback({ kind: LDFeedbackKind.Positive });
Deferred feedback across processes. If the thumbs-up UI fires in a different process than the one that produced the response, do not call create_tracker() again in the consumer — that mints a new runId. Persist the tracker's resumption token (tracker.resumption_token in Python, tracker.resumptionToken in Node) alongside the message, then rehydrate the tracker with LDAIConfigTracker.from_resumption_token(...) (Python) or aiClient.createTracker(token, context) (Node) in the feedback handler.
Verify. Hit the wrapped endpoint in staging, then open the config in LaunchDarkly → Monitoring tab. Duration, token, and generation counts should appear within 1–2 minutes. If nothing shows up, walk the checklist in sdk-ai-tracker-patterns.md under "Troubleshooting."
Step 5: Attach evaluations (Stage 5)
Decide between three evaluation paths. This is the most commonly misunderstood stage — there are three paths, not two, and the right default for a migration context is often the one people skip.
| Path |
When to use |
Supports agent mode? |
| Offline eval (recommended default for migration) |
Pre-ship regression: run a fixed dataset through the new variation in the LD Playground and score against baseline. Best fit for migration because you want to prove the new config behaves at least as well as the hardcoded version before shipping. |
Yes — all modes |
| UI-attached auto judges |
Attach one or more judges to a variation in the LD UI; judges run on sampled live requests automatically. Zero code changes. |
Completion mode only (the UI widget is completion-only today) |
| Programmatic direct-judge |
Call ai_client.create_judge(...) inside the request handler and judge.evaluate(input, output) on each call. Adds per-request cost and code complexity. Best for continuous live scoring of workflows where sampled auto-judges aren't enough. |
Yes — all modes (the SDK handles both identically) |
Most migration users should start with offline eval, then add programmatic direct-judge only if they need continuous live scoring after the rollout is stable.
For agent-mode migrations, default to offline eval. UI-attached auto judges are completion-mode only today. The documented path for agent mode is either (a) offline regression via the LD Playground + Datasets (works for all modes), or (b) programmatic direct-judge wired into the call site. Generate a starter dataset CSV from the audit manifest (one representative input per row) and point the user at the Offline Evals guide for the Playground walkthrough. Only wire programmatic direct-judge into production code if the user explicitly asks for continuous live scoring.
Recommended offline-eval shape for a migration:
- Run the
default variation (or whichever variation mirrors the pre-migration hardcoded behavior) against the dataset first — this is the baseline.
- Clone it into a second variation pointing at a different model family (e.g., if the baseline is
anthropic/claude-sonnet-4-5, clone to openai/gpt-4o or openai/gpt-4o-mini). The comparison is most informative across families, not across siblings.
- Attach the built-in Accuracy judge with a pass threshold of 0.85, and run both variations against the same dataset.
- Promote the winner to fallthrough via
/configs-targeting only if it beats the baseline on Accuracy and does not regress on Relevance or Toxicity.
Write this shape into the project's datasets/README.md (or equivalent) so the comparison pattern is reproducible after the migration ships.
Hand off to online-evals — only for UI-attached judges (completion mode) or to create custom judge configs that will be referenced by the programmatic path. Tell the user: "Run /online-evals with these inputs, then come back here." Do not auto-invoke. Pass:
- The parent config key and variation key
- A list of built-in judges (Accuracy, Relevance, Toxicity) or custom judge keys to create/attach
- Target environment
The delegate handles creating custom judge configs, attaching them via the variation PATCH endpoint, and setting fallthrough on each judge config. Offline eval does not go through this delegate — it's a Playground workflow, not an API write.
For programmatic direct-judge: wire create_judge + evaluate + track_judge_result. This is the only path at Stage 5 that writes code. The Python shape:
from ldai.client import AIJudgeConfigDefault
judge = ai_client.create_judge(
judge_key, # judge config key in LD
ld_context,
AIJudgeConfigDefault(enabled=False), # fallback: skip eval on SDK miss
)
if judge and judge.enabled:
result = await judge.evaluate(
input_text,
output_text,
sampling_rate=0.25, # optional; default 1.0 (always eval)
)
if result.sampled:
tracker.track_judge_result(result)
Four rules:
create_judge returns Optional[Judge]. Always guard with if judge and judge.enabled: — it returns None if the judge config is disabled for the context or the provider is missing. A direct .evaluate() on a None return will raise AttributeError.
- Pass
AIJudgeConfigDefault, not AICompletionConfigDefault. The create_judge default parameter is typed Optional[AIJudgeConfigDefault]; passing the completion type will not type-check and is a doc-level bug in some older examples.
sampling_rate is a parameter on evaluate(), not on create_judge. It defaults to 1.0 (evaluate every call). For live paths, pass something lower (0.1–0.25) to control cost.
evaluate() returns a JudgeResult (never None). Check result.sampled to know whether the evaluation actually ran, and call track_judge_result(result). Node uses trackJudgeResult(result) and LDJudgeResult with the same sampled field.
Ask the user which judge config key to use. LaunchDarkly ships three built-in judges — Accuracy, Relevance, Toxicity — but the actual config keys for the built-ins are not canonical SDK constants and aren't documented. Have the user open AgentControl > Library in the LD UI and copy the key of the judge they want to reference, or create a custom judge config via configs-create first.
Verify.
- UI-attached auto judges: trigger a request in staging, open the Monitoring tab → "Evaluator metrics" dropdown. Scores appear within 1–2 minutes at the configured sampling rate.
- Programmatic direct-judge: hit the wrapped endpoint and confirm
track_judge_result lands on the parent config's Monitoring tab.
- Offline eval: run the dataset through the LD Playground, compare baseline vs new-variation scores side by side. No runtime wiring required.
Delegate: online-evals (sub-step 3,
…(truncated)
1---2name: migrate3description: Migrate an application with hardcoded LLM prompts to a full LaunchDarkly AgentControl implementation in five stages: audit the code, wrap the call, move the tools, add tracking, attach evaluators. Use when the user wants to externalize model/prompt configuration, move from direct provider calls (OpenAI, Anthropic, Bedrock, Gemini, Strands) to a managed config, or stage a full hardcoded-to-LaunchDarkly migration.4license: Apache-2.05---6
7# Migrate to AgentControl
8
9You're using a skill that will guide you through migrating an application from hardcoded LLM prompts to a full LaunchDarkly AgentControl implementation. Your job is to run the migration in **five stages**, stopping at each stage for the user to confirm:
10
111. **Audit the code** — read-only scan that produces a structured list of everything hardcoded (prompt, model, parameters, tools, app-scoped knobs).
122. **Wrap the call** — install the SDK, create the config in LaunchDarkly with a fallback that mirrors the hardcoded values, and rewrite the call site to fetch the config fresh on every request.
133. **Move the tools** — extract each tool's JSON schema, attach it to the config, and swap every call site that references the old tool list.
144. **Add tracking** — wire the per-request tracker (duration, tokens, success/error) around the provider call.
155. **Attach evaluators** — either offline evals via the Playground + Datasets, or online judges that score sampled traffic automatically.
16
17> **⚠️ Three first-run failure modes to avoid.**
18>
19> 1. **Tracker in the wrong scope.** For an agent with a loop, mint `create_tracker()` once per user turn in a `setup_run` entry node — not inside `call_model`. Per-iteration factory calls produce N `runId`s and trip the at-most-once guards. See [agent-mode-frameworks.md § Custom `StateGraph`](references/agent-mode-frameworks.md).
20> 2. **`load_chat_model` wrapper reuse.** Templates like `langchain-ai/react-agent` ship a `load_chat_model(f"{provider}/{name}")` helper that wraps `init_chat_model(...)` and silently drops every variation parameter. **Delete it** (don't just avoid using it) and replace call sites with `create_langchain_model(ai_config)`.
21> 3. **Fallthrough not flipped after `/configs-create`.** A freshly-created config's fallthrough points at an auto-generated disabled variation, so the SDK returns `enabled=False` until `/configs-targeting` runs. Flip it before Stage 2 verification.
22
23## Coverage — which shapes are well-trodden vs require extrapolation
24
25The skill is optimized for Python and Node.js / TypeScript; other languages are install-only. Within Python and Node the coverage tiers are:
26
27| Shape | Python | Node.js | Reference |
28|-------|--------|---------|-----------|
29| One-shot completion (direct OpenAI / Anthropic / Bedrock / Gemini call) | ✅ Worked example | ✅ Worked example | [before-after-examples.md](references/before-after-examples.md), per-provider docs in `built-in-metrics/references/` |
30| Chat loop via managed runner (`ManagedModel`) | ✅ Tier 1 pattern | ✅ Tier 1 pattern | [built-in-metrics SKILL.md](../built-in-metrics/SKILL.md) |
31| LangChain single-call | ✅ Worked example | ✅ Worked example | [langchain-tracking.md](../built-in-metrics/references/langchain-tracking.md) |
32| LangGraph prebuilt agent (Python `langchain.agents.create_agent`, Node `createReactAgent`) | ✅ Worked example | ✅ Worked example | [agent-mode-frameworks.md § LangGraph](references/agent-mode-frameworks.md) |
33| LangGraph custom `StateGraph` with run-scoped tracker (setup_run + call_model + finalize) | ✅ Deep worked example | ⚠️ Mentioned — translate from Python | [agent-mode-frameworks.md § Custom `StateGraph`](references/agent-mode-frameworks.md) |
34| CrewAI `Agent` | ✅ Worked example | — (not a Node framework) | [agent-mode-frameworks.md § CrewAI](references/agent-mode-frameworks.md) |
35| Strands `Agent` | ✅ Worked example | ⚠️ BedrockModel + OpenAIModel only (no Anthropic) | [agent-mode-frameworks.md § Strands](references/agent-mode-frameworks.md) |
36| Custom ReAct loop (hand-rolled, any framework or none) | ✅ Worked example | ⚠️ Apply framework-agnostic invariants; translate from Python | [agent-mode-frameworks.md § Custom ReAct loop](references/agent-mode-frameworks.md) |
37| Vercel AI SDK (`generateText` / `streamText`) | — (not a Python framework) | ⚠️ Provider package exists; no worked example in skill | `built-in-metrics` provider-package matrix |
38| Streaming (SSE / WebSocket) | ⚠️ Delegated to `built-in-metrics` streaming doc | ⚠️ Same — use `trackStreamMetricsOf` + manual TTFT | [streaming-tracking.md](../built-in-metrics/references/streaming-tracking.md) |
39| Multi-agent graph (supervisor + workers) | ⚠️ Out of main scope; see reference | ⚠️ Out of main scope; see reference | [agent-graph-reference.md](references/agent-graph-reference.md) |
40| Non-LangGraph agent frameworks (Pydantic AI, DSPy, AutoGen, Haystack, LlamaIndex agents, Semantic Kernel) | ⚠️ Apply the three invariants; no framework-specific example | ⚠️ Same | [agent-mode-frameworks.md § Framework-agnostic invariants](references/agent-mode-frameworks.md) |
41| Go, Ruby, .NET | ℹ️ Install commands only | ℹ️ Install commands only | [phase-1-analysis-checklist.md § SDK routing table](references/phase-1-analysis-checklist.md) |
42
43**Reading the key:** ✅ = follow the skill verbatim; ⚠️ = the architecture applies but you'll translate idioms or cross-reference another skill; ℹ️ = skill doesn't go past the install step.
44
45If the target app is in the ⚠️ column, start by reading [agent-mode-frameworks.md § Framework-agnostic invariants](references/agent-mode-frameworks.md) — those three rules (one `agent_config` per turn, one tracker per turn, at-most-once methods fire once at turn end) apply regardless of framework, and every code snippet in this skill is an instantiation of them. Translate the Python example's shape onto the target framework's primitives.
46
47## Prerequisites
48
49This skill requires the remotely hosted LaunchDarkly MCP server to be configured in your environment, and an application that already calls an LLM provider with hardcoded model, prompt, and parameter values.
50
51**Required environment:**
52- `LD_SDK_KEY` — server-side SDK key (starts with `sdk-`) from the target LaunchDarkly project
53
54**MCP tools used directly by this skill:** none — every LaunchDarkly write happens in a focused sibling skill.
55
56**Check the SDK CHANGELOG before applying any pattern.** The API surface described throughout this skill targets the SDK behavior at the time of the skill's last update; SDK releases can rename, remove, or split methods after that. Before you start, fetch the latest CHANGELOG for the SDK(s) you'll target and skim for anything that contradicts the pattern you're about to apply:
57
58- Python: https://github.com/launchdarkly/python-server-sdk-ai/blob/main/packages/sdk/server-ai/CHANGELOG.md (and per-provider CHANGELOGs under `packages/ai-providers/server-ai-{openai,langchain}/CHANGELOG.md`)
59- Node: https://github.com/launchdarkly/js-core/blob/main/packages/sdk/server-ai/CHANGELOG.md (and per-provider CHANGELOGs under `packages/ai-providers/server-ai-{openai,langchain,vercel}/CHANGELOG.md`)
60
61If a CHANGELOG entry post-dates this skill and changes an API you're about to use, the CHANGELOG wins — and the skill should be updated.
62
63**Hand-off model.** This skill does **not** auto-invoke other skills. At each stage that needs a LaunchDarkly write, this skill prepares the inputs (config key, mode, model, prompt, tool schemas, judge keys) and then **tells the user to run the next slash-command themselves**. After the user finishes that sibling skill, return to the next step here. Treat the "Delegate" lines below as next-step instructions, not auto-handoffs.
64
65**Sibling skills the user runs at each stage:**
66- `projects` — pre-Stage 2, only if no project exists yet
67- `configs-create` — Stage 2 (creates the config and first variation)
68- `tools` — Stage 3 (creates tool definitions and attaches them)
69- `configs-targeting` — between Stage 2 and Stage 4 (promotes the new variation to fallthrough so the SDK actually serves it)
70- `online-evals` — Stage 5 (attaches judges, creates custom judges)
71
72## Core Principles
73
741. **Inspect before you mutate.** Every stage begins with a read-only audit. Do not touch code until Step 1 is confirmed by the user.
752. **Replace config, not business logic.** The SDK call is a drop-in for the place where the model, parameters, and prompt are *defined* — not for the provider call itself. OpenAI/Anthropic/Bedrock calls stay where they are.
763. **Fallback mirrors current behavior.** The fallback passed to `completion_config` / `agent_config` must preserve the hardcoded values you removed, so the app is unchanged if LaunchDarkly is unreachable.
774. **Stages are ordered.** Wrap before you add tools. Add tools before you track. Track before you add evals. Skipping ahead produces configs without traffic, metrics without context, and judges with nothing to score.
785. **Hand off to focused skills, manually.** Each stage that needs a LaunchDarkly write tells the user to run a sibling slash-command (`/configs-create`, `/tools`, `/configs-targeting`, `/online-evals`) and waits for them to come back. This skill does **not** auto-invoke other skills.
79
80## Workflow
81
82### Minimum viable migration
83
84Stages 1–4 (audit, wrap, tools, tracker) are independently shippable. **A migration that stops after Stage 4 is complete, production-ready, and delivers the core value** — externalized prompts and model config, targeting, variation A/B testing, and Monitoring-tab metrics. Stage 5 (evaluators) is a quality-of-life addition, not a gate. Do not block a Stage-4 rollout on evaluators; ship the run-scoped tracker path, verify metrics flow, then come back for Stage 5 when the team has time to curate a dataset.
85
86That said, do not *skip* Stage 4. A migration without the tracker gives you externalized prompts but no visibility, which is most of the payoff left on the floor.
87
88### Step 1: Audit the codebase (Stage 1)
89
90This is the first stage. It is **read-only** — no code writes, no LaunchDarkly resources created. The goal is to scan the repo and produce a structured manifest of every hardcoded value that needs to move, then hand the manifest back to the user for confirmation before any code is touched in Stage 2.
91
92Use [phase-1-analysis-checklist.md](references/phase-1-analysis-checklist.md) to scan:
93
941. **Language and package manager** — Python (pip/poetry/uv), TypeScript/JavaScript (npm/pnpm/yarn), Go, Ruby, .NET
952. **LLM provider** — OpenAI, Anthropic, Bedrock, Gemini, LangChain, LangGraph, CrewAI, Strands
963. **Existing LaunchDarkly usage** — any pre-existing `LDClient` or `ldclient` initialization to reuse
974. **Hardcoded model configs** — model name string literals, temperature / max_tokens / top_p, system prompts, instruction strings
985. **Template placeholders in prompts** — `.format()` calls, f-strings in prompt constants, JS/TS template literals, `%(var)s`, hand-rolled `str.replace("__VAR__", ...)`. Flag each placeholder name and its runtime-value source; all get rewritten to Mustache `{{ variable }}` in Stage 2.
996. **Externalized prompt files** — scan YAML / JSON / TOML / Markdown / `.prompt` / `.j2` files **and** prompt-template registries (`langchain.hub.pull(...)`, LangSmith `client.pull_prompt(...)`) for prompts loaded at runtime. Common shapes: CrewAI `agents.yaml` / `tasks.yaml`, LangChain Promptfiles, k8s ConfigMap overlays, Pydantic Settings classes with `prompt_*` fields. Same Mustache rewrite (sub-step 5 of Stage 2) applies if the placeholder syntax differs. See [phase-1-analysis-checklist.md § 4](references/phase-1-analysis-checklist.md).
1007. **Hardcoded app-scoped knobs** — search-result limits, retry budgets, tool-timeout overrides, feature toggles, any config-dataclass field that isn't a prompt or model parameter but still governs agent behavior. These belong in `model.custom` on the variation (not `model.parameters`, which is forwarded to the provider SDK and will crash on unknown kwargs).
1018. **Mode decision** — completion mode (chat messages array) or agent mode (single instructions string). Completion mode is the default and the only mode that supports judges attached in the UI.
102
103For each hardcoded target the audit finds, record:
104
105- File path and line range
106- Current value (model name, full prompt text, parameter dict)
107- Target config field (`model.name`, `model.parameters.temperature`, `messages[].content`, `instructions`)
108- Whether the surrounding call uses function calling / tools (drives Stage 3)
109- Whether the surrounding call has retry logic (affects where Stage 4 tracker calls go)
110
111This manifest is the contract for the next four stages.
112
113**Stage 1 output** (return to user as a structured summary):
114
115```
116Language: Python 3.12
117Package manager: uv
118LLM provider: OpenAI
119Existing LD SDK: none
120Target mode: completion
121Hardcoded targets:
122 - src/chat.py:42 model="gpt-4o"
123 - src/chat.py:43 temperature=0.7, max_tokens=2000
124 - src/chat.py:45 system="You are a helpful assistant..."
125Externalized prompt files: none (or e.g. "prompts/agents.yaml — CrewAI role/goal/backstory")
126Prompt-template registries: none (or e.g. langchain.hub.pull("rlm/rag-prompt") at app.py:14)
127Coverage totals: 3 hardcoded code targets · 0 externalized prompt files · 0 registry pulls
128Proposed plan: single config key `chat-assistant`, mirror fallback, Stage 3 (tools) skipped (no function calling), Stage 4 (tracking) inline, Stage 5 (evals) attach built-in accuracy judge.
129```
130
131**STOP.** Present this summary, state the coverage totals out loud (e.g. "I found **N** hardcoded code targets and **M** externalized prompt files — does that match what you expected?"), and wait for the user to reply with one of four explicit forms:
132
133- **`confirm`** — proceed to Stage 2.
134- **`add: <files or paths>`** — re-run the audit with the new locations and present an updated summary.
135- **`fix: <correction>`** — update a target in the list (provider, mode, prompt content, etc.) and ask again.
136- **`stop`** — pause the migration here.
137
138Do not interpret any other word — including `skip`, `next`, `go`, `ok`, `proceed` — as confirmation; ask the user to pick one of the four forms. **This is the most important checkpoint in the workflow** — if the audit is wrong, every stage after this will be wrong. The user should cross-check the hardcoded-targets list against what they know is in the code before giving the go-ahead.
139
140### Step 2: Wrap the call in the AI SDK (Stage 2)
141
142This is the first stage that writes code. It has nine sub-steps.
143
1441. **Delete any hand-rolled model / tool wrappers the audit flagged.** Do this *before* installing the new SDK so the replacement lands in a repo without confusing fallback imports. The two shapes the Stage 1 audit should have surfaced:
145 - **`load_chat_model(f"{provider}/{name}")` or any `init_chat_model(...)` wrapper.** Ships with `langchain-ai/react-agent` and many derivative repos. Delete the function and its module; the replacement is `create_langchain_model(ai_config)` (installed in the next sub-step). Leaving the wrapper in place means the next edit in this repo will import the familiar helper and silently drop variation parameters.
146 - **Hand-rolled `resolve_tools` / `TOOL_REGISTRY` / `ALL_TOOLS` helpers that hard-code a static tool list.** Delete them; `ldai_langchain.langchain_helper.build_structured_tools(ai_config, TOOL_REGISTRY_DICT)` is the canonical replacement and gets wired in Stage 3. If you leave the hand-rolled version, both shapes will live side-by-side and the next contributor will pick the familiar one.
147
148 Commit the deletion separately from the SDK install if the repo's review process benefits from it — otherwise bundle with sub-step 2.
149
1502. **Install the AI SDK.** Detect the package manager from Step 1, then install:
151 - Python: `launchdarkly-server-sdk` + `launchdarkly-server-sdk-ai>=0.20.0`
152 - Node.js/TypeScript: `@launchdarkly/node-server-sdk` + `@launchdarkly/server-sdk-ai@^0.20.0`
153 - Go: `github.com/launchdarkly/go-server-sdk/v7` + `github.com/launchdarkly/go-server-sdk/ldai`
154
155 Tier-2 provider packages (install in Stage 4, only if you're using the matching provider):
156 - OpenAI: `launchdarkly-server-sdk-ai-openai>=0.4.0` (Python) / `@launchdarkly/server-sdk-ai-openai@^0.5.5` (Node)
157 - LangChain / LangGraph: `launchdarkly-server-sdk-ai-langchain>=0.5.0` (Python) / `@launchdarkly/server-sdk-ai-langchain@^0.5.5` (Node)
158 - Vercel AI SDK (Node only): `@launchdarkly/server-sdk-ai-vercel@^0.5.5`
159 - Anthropic, Gemini, Bedrock — no provider package published; use Tier-3 custom extractor (see `built-in-metrics`)
160
1613. **Initialize `LDAIClient` once at startup.** Reuse any existing `LDClient` — do not create a second base client. Place the initialization in the same module that owns existing app config.
162
163 **Python:**
164 ```python
165 import os
166 import ldclient
167 from ldclient.config import Config
168 from ldai.client import LDAIClient
169
170 # Order matters: ldclient.get() raises if called before ldclient.set_config().
171 # The set_config call is what initializes the singleton; .get() just returns it.
172 sdk_key = os.environ.get("LD_SDK_KEY")
173 if sdk_key:
174 ldclient.set_config(Config(sdk_key))
175 else:
176 # Missing key: init in offline mode so the app still starts and the fallback
177 # path runs on every call. Never raise at import time for a missing env var —
178 # that turns a config gap into a boot failure.
179 import logging
180 logging.getLogger(__name__).warning(
181 "LD_SDK_KEY not set; configs will use fallback values only."
182 )
183 ldclient.set_config(Config("", offline=True))
184
185 ai_client = LDAIClient(ldclient.get())
186 ```
187
188 **Node.js/TypeScript:**
189 ```typescript
190 import { init } from '@launchdarkly/node-server-sdk';
191 import { initAi } from '@launchdarkly/server-sdk-ai';
192
193 // The Node SDK does not have an explicit offline mode — a missing or invalid
194 // key fails fast during waitForInitialization, and every agent_config /
195 // completion_config call returns the fallback. Log a warning; do not throw.
196 if (!process.env.LD_SDK_KEY) {
197 console.warn('LD_SDK_KEY not set; configs will use fallback values only.');
198 }
199 const ldClient = init(process.env.LD_SDK_KEY ?? 'sdk-offline');
200 await ldClient.waitForInitialization({ timeout: 10 }).catch(() => {
201 // Swallow init failures in offline mode; fallback path runs.
202 });
203 const aiClient = initAi(ldClient);
204 ```
205
2064. **Hand off to `configs-create`.** Print the extracted model, prompt/instructions, parameters, and mode from the Stage 1 manifest, then tell the user: *"Run `/configs-create` with these inputs, then come back here."* Supply the config key you want the code to call (e.g. `chat-assistant`). Do not attempt to auto-invoke the sibling skill — wait for the user to finish it before continuing.
207
208 **After `configs-create` finishes, the user must also run `/configs-targeting` to promote the new variation to fallthrough.** A freshly created variation returns `enabled=False` to every consumer until targeting is updated. Skip this and Stage 2 verification (sub-step 9 below) will silently take the fallback path on every request.
209
2105. **Rewrite template placeholders to Mustache syntax.** If the hardcoded prompt interpolates runtime values with Python `.format()`, f-strings, JS template literals, or any other non-Mustache syntax (e.g. `{system_time}`, `${userName}`, `%(topic)s`), rewrite every placeholder to `{{ variable }}` Mustache form. Do this in **both** the file you're about to send to `/configs-create` *and* the fallback string you'll write in sub-step 6. The AI SDK interpolates variables through a Mustache renderer on the LD-served path *and* the fallback path using the fourth-argument `variables` dict to `completion_config(...)` / `completionConfig(...)`. Leaving a Python-style `{system_time}` literal in the fallback ships a silent regression when LaunchDarkly is unreachable — the renderer won't match the single-brace form and the literal `{system_time}` goes to the provider as part of the prompt.
211
212 **Before:**
213 ```python
214 SYSTEM_PROMPT = "You are a helpful assistant. The time is {system_time}."
215 prompt = SYSTEM_PROMPT.format(system_time=datetime.now().isoformat())
216 ```
217
218 **After (in source):**
219 ```python
220 SYSTEM_PROMPT = "You are a helpful assistant. The time is {{ system_time }}."
221 # .format() is removed at the call site — the SDK interpolates via `variables`
222 config = ai_client.completion_config(
223 CONFIG_KEY,
224 context,
225 fallback,
226 variables={"system_time": datetime.now().isoformat()},
227 )
228 ```
229
230 Common shapes to rewrite:
231 - Python `"{var}"` / `"{var!s}"` / `"%(var)s"` → `"{{ var }}"`
232 - JS/TS `` `${var}` `` template literals inside prompt strings → `"{{ var }}"`
233 - Any hand-rolled `str.replace("__VAR__", value)` scheme → `"{{ var }}"`
234
235 See [fallback-defaults-pattern.md § Template placeholders](references/fallback-defaults-pattern.md) for the fallback-specific variant.
236
2376. **Build the fallback.** Mirror the hardcoded values you extracted. Use `AICompletionConfigDefault` / `AIAgentConfigDefault` in Python, plain object literals in Node. See [fallback-defaults-pattern.md](references/fallback-defaults-pattern.md) for inline, file-backed, and bootstrap-generated patterns.
238
239 **Python fallback (completion mode):**
240 ```python
241 from ldai.client import AICompletionConfigDefault, ModelConfig, ProviderConfig, LDMessage
242
243 fallback = AICompletionConfigDefault(
244 enabled=True,
245 model=ModelConfig(name="gpt-4o", parameters={"temperature": 0.7, "max_tokens": 2000}),
246 provider=ProviderConfig(name="openai"),
247 messages=[LDMessage(role="system", content="You are a helpful assistant...")],
248 )
249 ```
250
2517. **Replace the hardcoded call site.** Swap the hardcoded model/prompt/params for a `completion_config` / `completionConfig` (or `agent_config` / `agentConfig`) call, then read the returned fields into the existing provider call. Keep the provider call intact.
252
253 **Python — before:**
254 ```python
255 response = openai_client.chat.completions.create(
256 model="gpt-4o",
257 temperature=0.7,
258 max_tokens=2000,
259 messages=[
260 {"role": "system", "content": "You are a helpful assistant..."},
261 {"role": "user", "content": user_input},
262 ],
263 )
264 ```
265
266 **Python — after:**
267 ```python
268 context = Context.builder(user_id).set("email", user.email).build()
269 config = ai_client.completion_config("chat-assistant", context, fallback)
270
271 if not config.enabled:
272 return disabled_response()
273
274 params = config.model.parameters or {}
275 response = openai_client.chat.completions.create(
276 model=config.model.name,
277 temperature=params.get("temperature"),
278 max_tokens=params.get("max_tokens"),
279 messages=[m.to_dict() for m in (config.messages or [])] + [
280 {"role": "user", "content": user_input},
281 ],
282 )
283 ```
284
285 **Python — after (agent mode)** — for LangGraph, CrewAI, or any framework that takes a goal/instructions string:
286
287 ```python
288 context = Context.builder(user_id).kind("user").build()
289 config = ai_client.agent_config("support-agent", context, FALLBACK)
290
291 if not config.enabled:
292 return disabled_response()
293
294 # config is a single AIAgentConfig object — NOT a (config, tracker) tuple.
295 # Obtain the tracker once per execution via the factory: tracker = config.create_tracker()
296 model_name = f"{config.provider.name}/{config.model.name}"
297 instructions = config.instructions
298 params = config.model.parameters or {}
299
300 # Pass model_name + instructions into your framework's agent constructor.
301 # Example: LangGraph prebuilt agent (Python — `from langchain.agents import create_agent`;
302 # this replaces `langgraph.prebuilt.create_react_agent`, deprecated in LangGraph 1.0
303 # and removed in 2.0. Same return shape; `prompt=` was renamed to `system_prompt=`.)
304 # agent = create_agent(
305 # create_langchain_model(config), # forwards every variation parameter
306 # TOOLS, # Stage 3 will replace this with a config.tools loader
307 # system_prompt=instructions,
308 # )
309 ```
310
311 See [before-after-examples.md](references/before-after-examples.md) for full Python OpenAI, Node Anthropic, and LangGraph agent-mode paired snippets.
312
3138. **Check `config.enabled`.** If it returns `False`, handle the disabled path without crashing and without calling the provider. The check is required — not optional.
314
3159. **Verify.** Run the app with a valid `LD_SDK_KEY`; confirm the call succeeds and the response matches pre-migration output. Then temporarily set `LD_SDK_KEY=sdk-invalid` (or unset it) and confirm the fallback path runs without error. Both paths must work before moving to Stage 3.
316
317Delegate: **`configs-create`** (sub-step 4).
318
319### Step 3: Move tools into the config (Stage 3)
320
321Skip this step if the audited app has no function calling / tools. Otherwise:
322
3231. **Enumerate the tools currently registered.** Common shapes to look for:
324
325 - `openai.chat.completions.create(tools=[...])` — OpenAI direct
326 - `anthropic.messages.create(tools=[...])` — Anthropic direct
327 - `create_agent(llm, tools=[...], system_prompt=...)` — LangGraph prebuilt (Python, `langchain.agents`; replaces deprecated `langgraph.prebuilt.create_react_agent`)
328 - `createReactAgent({ llm, tools: [...] })` — LangGraph.js prebuilt (Node, `@langchain/langgraph/prebuilt`)
329 - `Agent(tools=[...])` — CrewAI
330 - `Agent(tools=[...])` — Strands (Python `@tool`-decorated callables passed through the constructor; TS SDK uses Zod-schema tools)
331 - **Custom `StateGraph`** — module-level `TOOLS = [...]` list referenced in **both** `model.bind_tools(TOOLS)` and `ToolNode(TOOLS)`. This is the `langchain-ai/react-agent` template shape; the list is usually in a `tools.py` module. Grep for `bind_tools(` and `ToolNode(` together — they will point at the same list.
332
333 Record each tool's name, description, and JSON schema.
334
335 For LangChain/LangGraph tools defined with `@tool`, extract the schema via `tool.args_schema.model_json_schema()` (or the equivalent Pydantic `model_json_schema()` call). For plain async callables used as tools (common in custom StateGraph shapes), LangChain infers the schema from the function signature at bind time — extract it via `StructuredTool.from_function(fn).args_schema.model_json_schema()`. Do not hand-write the schema.
336
3372. **Hand off to `tools`.** Print the extracted tool names, descriptions, and schemas, then tell the user: *"Run `/tools` with these tools and the variation key, then come back here."* The sibling skill creates tool definitions (`create-ai-tool`) and attaches them to the variation (`update-ai-config-variation`). Wait for the user to finish before proceeding to sub-step 3. Do not auto-invoke.
338
3393. **Replace the hardcoded tools array at the call site** with a read from `config.tools` (or the SDK equivalent for your language). Load the actual implementation functions dynamically from the tool names — see [agent-mode-frameworks.md](references/agent-mode-frameworks.md) for the dynamic-tool-factory pattern from the devrel agents tutorial.
340
341 **For custom `StateGraph` shapes**, you must update **both** call sites: `.bind_tools(TOOLS)` and `ToolNode(TOOLS)` must both read from the same `config.tools`-derived list. Forgetting one leaves the LLM seeing the new tools but the executor still running the old ones, or vice versa.
342
3434. **Verify.** Run the app; confirm the tool flows still execute correctly. `get-ai-config` (via the delegate) confirms the tools are attached server-side.
344
345Delegate: **`tools`** (sub-step 2).
346
347### Step 4: Instrument the tracker (Stage 4)
348
349Delegate: **`built-in-metrics`** wires the per-request `tracker.track_*` calls (duration, tokens, success/error, feedback) around the provider call. Use **`custom-metrics`** alongside it if the app needs business metrics beyond the built-in agent ones. Note: do not confuse this with `launchdarkly-metric-instrument`, which is for `ldClient.track()` feature metrics — a different API. See [sdk-ai-tracker-patterns.md](references/sdk-ai-tracker-patterns.md) for the full per-method Python + Node matrix that the delegate skill draws on.
350
351Hand off: print the config key, variation key, provider, and whether the call is streaming, then tell the user: *"Run `/built-in-metrics` with these inputs, then come back here."* Do not auto-invoke. Return here for sub-step 5 (verify) once they're done.
352
3531. **Create the tracker.** Obtain a per-execution tracker via the factory on the config returned in Stage 2: `tracker = config.create_tracker()` (Python) or `const tracker = aiConfig.createTracker();` (Node). Call the factory **once per user turn** and reuse the returned `tracker` for every tracking call in that turn — each call mints a fresh `runId` that tags every event emitted from the turn so they can be correlated via exported events or downstream queries. (The Monitoring tab aggregates today; run-level grouping is a downstream concern — but the `runId` is also what the SDK's at-most-once guards are keyed on, so minting a new one mid-turn breaks the guard semantics regardless of where the events end up.)
354
355 **Where to call the factory depends on the call shape:**
356
357 - **Completion mode / one-shot provider call:** mint the tracker right after `completion_config(...)` returns, in the same function that handles the request.
358 - **Agent mode with a ReAct loop (LangGraph, LangChain, custom):** mint the tracker in a dedicated `setup_run` entry node that executes **once** before the loop, stash it on graph state, and read it from state in `call_model` / tool handlers / a terminal `finalize` node. Emitting `track_duration` / `track_tokens` / `track_success` inside the loop body will trip the at-most-once guards. See [agent-mode-frameworks.md § Custom `StateGraph` (run-scoped architecture)](references/agent-mode-frameworks.md) for the full `setup_run` + `call_model` + `finalize` pattern.
359 - **Managed runner (Tier 1):** skip this step entirely. `ManagedModel` mints the tracker internally per `run()` / `invoke()`. Move to sub-step 4 if that's what the app uses.
360
3612. **Pick a tier from the four-tier ladder.** See [sdk-ai-tracker-patterns.md § Tier decision table](references/sdk-ai-tracker-patterns.md) for the full table (chat loop → Tier 1; provider-package call → Tier 2; custom extractor → Tier 3; streaming/manual → Tier 4).
362
3633. **Wire the chosen tier.** The delegate skill has full Python + Node examples for each tier plus per-provider files. A condensed Tier 2/3 example for reference — OpenAI via the provider package:
364
365 **Python:**
366 ```python
367 from ldai_openai import get_ai_metrics_from_response
368 import openai
369
370 client = openai.OpenAI()
371
372 tracker = config.create_tracker()
373
374 def call_openai():
375 return client.chat.completions.create(
376 model=config.model.name,
377 messages=[{"role": "system", "content": config.messages[0].content},
378 {"role": "user", "content": user_prompt}],
379 )
380
381 # Exceptions are tracked automatically — track_metrics_of catches
382 # exceptions, records tracker.track_error(), and re-raises. Wrap your
383 # own try/except only for local handling (logging, fallback).
384 response = tracker.track_metrics_of(get_ai_metrics_from_response, call_openai)
385 ```
386
387 **Node:**
388 ```typescript
389 import { getAIMetricsFromResponse } from '@launchdarkly/server-sdk-ai-openai';
390
391 const tracker = aiConfig.createTracker();
392 // Exceptions are tracked automatically — trackMetricsOf catches
393 // exceptions, records tracker.trackError(), and re-throws.
394 const response = await tracker.trackMetricsOf(
395 getAIMetricsFromResponse,
396 () => openaiClient.chat.completions.create({
397 model: aiConfig.model!.name,
398 messages: [...aiConfig.messages, { role: 'user', content: userPrompt }],
399 }),
400 );
401 ```
402
403 For Anthropic direct, Bedrock (no provider package), Gemini, and custom HTTP, write a small extractor returning `LDAIMetrics` — see the delegate skill's [anthropic-tracking.md](../built-in-metrics/references/anthropic-tracking.md), [bedrock-tracking.md](../built-in-metrics/references/bedrock-tracking.md), and [gemini-tracking.md](../built-in-metrics/references/gemini-tracking.md). LangChain single-node and LangGraph go through the `launchdarkly-server-sdk-ai-langchain` / `@launchdarkly/server-sdk-ai-langchain` provider package. Build the model with `create_langchain_model(config)` (Python) / `createLangChainModel(config)` (Node) — both forward all variation parameters — and track with `get_ai_metrics_from_response` / `getAIMetricsFromResponse`. See [langchain-tracking.md](../built-in-metrics/references/langchain-tracking.md).
404
4054. **Wire feedback tracking if the app has thumbs-up/down UI.** Both SDKs expose `trackFeedback` with a `{kind}` argument.
406
407 **Python:**
408 ```python
409 from ldai.tracker import FeedbackKind
410 tracker.track_feedback({"kind": FeedbackKind.Positive})
411 ```
412
413 **Node:**
414 ```typescript
415 import { LDFeedbackKind } from '@launchdarkly/server-sdk-ai';
416 tracker.trackFeedback({ kind: LDFeedbackKind.Positive });
417 ```
418
419 **Deferred feedback across processes.** If the thumbs-up UI fires in a different process than the one that produced the response, do **not** call `create_tracker()` again in the consumer — that mints a new `runId`. Persist the tracker's resumption token (`tracker.resumption_token` in Python, `tracker.resumptionToken` in Node) alongside the message, then rehydrate the tracker with `LDAIConfigTracker.from_resumption_token(...)` (Python) or `aiClient.createTracker(token, context)` (Node) in the feedback handler.
420
4215. **Verify.** Hit the wrapped endpoint in staging, then open the config in LaunchDarkly → Monitoring tab. Duration, token, and generation counts should appear within 1–2 minutes. If nothing shows up, walk the checklist in [sdk-ai-tracker-patterns.md](references/sdk-ai-tracker-patterns.md) under "Troubleshooting."
422
423### Step 5: Attach evaluations (Stage 5)
424
4251. **Decide between three evaluation paths.** This is the most commonly misunderstood stage — there are **three** paths, not two, and the right default for a migration context is often the one people skip.
426
427 | Path | When to use | Supports agent mode? |
428 |------|-------------|---------------------|
429 | **Offline eval** (recommended default for migration) | Pre-ship regression: run a fixed dataset through the new variation in the LD Playground and score against baseline. Best fit for migration because you want to prove the new config behaves at least as well as the hardcoded version before shipping. | Yes — all modes |
430 | **UI-attached auto judges** | Attach one or more judges to a variation in the LD UI; judges run on sampled live requests automatically. Zero code changes. | Completion mode only (the UI widget is completion-only today) |
431 | **Programmatic direct-judge** | Call `ai_client.create_judge(...)` inside the request handler and `judge.evaluate(input, output)` on each call. Adds per-request cost and code complexity. Best for continuous live scoring of workflows where sampled auto-judges aren't enough. | Yes — all modes (the SDK handles both identically) |
432
433 **Most migration users should start with offline eval**, then add programmatic direct-judge only if they need continuous live scoring after the rollout is stable.
434
4352. **For agent-mode migrations, default to offline eval.** UI-attached auto judges are completion-mode only today. The documented path for agent mode is either (a) **offline regression** via the LD Playground + Datasets (works for all modes), or (b) **programmatic direct-judge** wired into the call site. Generate a starter dataset CSV from the audit manifest (one representative input per row) and point the user at the [Offline Evals guide](https://docs.launchdarkly.com/guides/ai-configs/offline-evaluations) for the Playground walkthrough. Only wire programmatic direct-judge into production code if the user explicitly asks for continuous live scoring.
436
437 **Recommended offline-eval shape for a migration:**
438 - Run the `default` variation (or whichever variation mirrors the pre-migration hardcoded behavior) against the dataset first — this is the baseline.
439 - Clone it into a second variation pointing at a **different model family** (e.g., if the baseline is `anthropic/claude-sonnet-4-5`, clone to `openai/gpt-4o` or `openai/gpt-4o-mini`). The comparison is most informative across families, not across siblings.
440 - Attach the built-in **Accuracy** judge with a pass threshold of **0.85**, and run both variations against the same dataset.
441 - Promote the winner to fallthrough via `/configs-targeting` only if it beats the baseline on Accuracy and does not regress on Relevance or Toxicity.
442
443 Write this shape into the project's `datasets/README.md` (or equivalent) so the comparison pattern is reproducible after the migration ships.
444
4453. **Hand off to `online-evals`** — only for UI-attached judges (completion mode) or to create custom judge configs that will be referenced by the programmatic path. Tell the user: *"Run `/online-evals` with these inputs, then come back here."* Do not auto-invoke. Pass:
446 - The parent config key and variation key
447 - A list of built-in judges (Accuracy, Relevance, Toxicity) or custom judge keys to create/attach
448 - Target environment
449
450 The delegate handles creating custom judge configs, attaching them via the variation PATCH endpoint, and setting fallthrough on each judge config. Offline eval does **not** go through this delegate — it's a Playground workflow, not an API write.
451
4524. **For programmatic direct-judge: wire `create_judge` + `evaluate` + `track_judge_result`.** This is the only path at Stage 5 that writes code. The Python shape:
453
454 ```python
455 from ldai.client import AIJudgeConfigDefault
456
457 judge = ai_client.create_judge(
458 judge_key, # judge config key in LD
459 ld_context,
460 AIJudgeConfigDefault(enabled=False), # fallback: skip eval on SDK miss
461 )
462
463 if judge and judge.enabled:
464 result = await judge.evaluate(
465 input_text,
466 output_text,
467 sampling_rate=0.25, # optional; default 1.0 (always eval)
468 )
469 if result.sampled:
470 tracker.track_judge_result(result)
471 ```
472
473 Four rules:
474 - **`create_judge` returns `Optional[Judge]`.** Always guard with `if judge and judge.enabled:` — it returns `None` if the judge config is disabled for the context or the provider is missing. A direct `.evaluate()` on a `None` return will raise `AttributeError`.
475 - **Pass `AIJudgeConfigDefault`**, not `AICompletionConfigDefault`. The `create_judge` `default` parameter is typed `Optional[AIJudgeConfigDefault]`; passing the completion type will not type-check and is a doc-level bug in some older examples.
476 - **`sampling_rate` is a parameter on `evaluate()`**, not on `create_judge`. It defaults to `1.0` (evaluate every call). For live paths, pass something lower (0.1–0.25) to control cost.
477 - **`evaluate()` returns a `JudgeResult`** (never `None`). Check `result.sampled` to know whether the evaluation actually ran, and call `track_judge_result(result)`. Node uses `trackJudgeResult(result)` and `LDJudgeResult` with the same `sampled` field.
478
479 **Ask the user which judge config key to use.** LaunchDarkly ships three built-in judges — Accuracy, Relevance, Toxicity — but the actual config **keys** for the built-ins are not canonical SDK constants and aren't documented. Have the user open **AgentControl > Library** in the LD UI and copy the key of the judge they want to reference, or create a custom judge config via `configs-create` first.
480
4815. **Verify.**
482 - **UI-attached auto judges:** trigger a request in staging, open the Monitoring tab → "Evaluator metrics" dropdown. Scores appear within 1–2 minutes at the configured sampling rate.
483 - **Programmatic direct-judge:** hit the wrapped endpoint and confirm `track_judge_result` lands on the parent config's Monitoring tab.
484 - **Offline eval:** run the dataset through the LD Playground, compare baseline vs new-variation scores side by side. No runtime wiring required.
485
486Delegate: **`online-evals`** (sub-step 3,
487
488…(truncated)