All skills
launchdarkly avatar

/migrate

@f1bea2f official

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.

Use this Skill: https://skilld.dev/gh/launchdarkly/agent-skills/migrate

This session only. Nothing lands on disk.

referencesbefore-after-examples.md

≈2.8k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Before/After Examples

Paired code snippets for the Stage 2 call-site swap. Each example shows the same call before extraction and after wrapping with the LaunchDarkly AI SDK. Tools and tracking are deliberately not shown here — they are layered on in Stages 3 and 4. See sdk-ai-tracker-patterns.md for the tracking overlay and agent-mode-frameworks.md for the tool-loading pattern.


Example 1: Python + OpenAI — completion mode

A typical one-shot chat app. Hardcoded model, temperature, max tokens, and system prompt.

Before

from openai import OpenAI

openai_client = OpenAI()

def answer(user_question: str) -> str:
    response = openai_client.chat.completions.create(
        model="gpt-4o",
        temperature=0.7,
        max_tokens=2000,
        messages=[
            {"role": "system", "content": "You are a helpful assistant. Answer concisely."},
            {"role": "user", "content": user_question},
        ],
    )
    return response.choices[0].message.content

After

import ldclient
from ldclient import Context
from ldclient.config import Config
from ldai.client import LDAIClient, AICompletionConfigDefault, ModelConfig, ProviderConfig, LDMessage
from openai import OpenAI

openai_client = OpenAI()

ldclient.set_config(Config(os.environ["LD_SDK_KEY"]))
ai_client = LDAIClient(ldclient.get())

# Fallback mirrors the hardcoded values that were removed.
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. Answer concisely.")],
)

def answer(user_id: str, user_question: str) -> str:
    context = Context.builder(user_id).kind("user").build()
    config = ai_client.completion_config("chat-assistant", context, FALLBACK)

    if not config.enabled:
        return ""  # handle disabled path

    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_question},
        ],
    )
    return response.choices[0].message.content

What changed

  • Model/params/prompt no longer appear as string literals — they come from config.model.name, config.model.parameters, and config.messages
  • LDAIClient is initialized once at import time
  • A Context is built per request from user_id (targeting happens here)
  • Fallback is an AICompletionConfigDefault that mirrors the removed hardcoded values
  • config.enabled is checked before calling the provider
  • The provider call itself is unchanged — same openai_client.chat.completions.create, same return shape

Example 2: Node.js + Anthropic — completion mode

Anthropic separates the system message from the messages array, so this example shows the convertToAnthropicFormat helper used in the relaunch guide.

Before

import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic();

export async function answer(userQuestion: string): Promise<string> {
  const response = await anthropic.messages.create({
    model: 'claude-sonnet-4-5',
    max_tokens: 1024,
    system: 'You are a helpful assistant. Answer concisely.',
    messages: [{ role: 'user', content: userQuestion }],
  });
  const text = response.content.find((b) => b.type === 'text');
  return text?.type === 'text' ? text.text : '';
}

After

import Anthropic from '@anthropic-ai/sdk';
import { init, LDContext } from '@launchdarkly/node-server-sdk';
import { initAi, LDAICompletionConfigDefault } from '@launchdarkly/server-sdk-ai';

const anthropic = new Anthropic();
const ldClient = init(process.env.LD_SDK_KEY!);
await ldClient.waitForInitialization({ timeout: 10 });
const aiClient = initAi(ldClient);

const FALLBACK: LDAICompletionConfigDefault = {
  enabled: true,
  model: {
    name: 'claude-sonnet-4-5',
    parameters: { max_tokens: 1024 },
  },
  provider: { name: 'anthropic' },
  messages: [
    { role: 'system', content: 'You are a helpful assistant. Answer concisely.' },
  ],
};

function convertToAnthropicFormat(ldMessages?: Array<{ role: string; content: string }>) {
  let systemMessage: string | undefined;
  const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
  for (const msg of ldMessages ?? []) {
    if (msg.role === 'system') {
      systemMessage = msg.content;
    } else {
      messages.push({ role: msg.role as 'user' | 'assistant', content: msg.content });
    }
  }
  return { systemMessage, messages };
}

export async function answer(userId: string, userQuestion: string): Promise<string> {
  const context: LDContext = { kind: 'user', key: userId };
  const aiConfig = await aiClient.completionConfig('chat-assistant', context, FALLBACK);

  if (!aiConfig.enabled) return '';

  const { systemMessage, messages } = convertToAnthropicFormat(aiConfig.messages);
  messages.push({ role: 'user', content: userQuestion });

  const response = await anthropic.messages.create({
    model: aiConfig.model?.name ?? 'claude-sonnet-4-5',
    max_tokens: (aiConfig.model?.parameters?.max_tokens as number) ?? 1024,
    system: systemMessage,
    messages,
  });

  const text = response.content.find((b) => b.type === 'text');
  return text?.type === 'text' ? text.text : '';
}

What changed

  • Hardcoded model + system prompt + max_tokens are gone
  • initAi(ldClient) wraps the base client once at import
  • convertToAnthropicFormat hoists the system message out of the LDMessage array (since Anthropic takes system as a top-level param, not a role in messages)
  • aiConfig.enabled is checked; the disabled path returns an empty string
  • Provider call is otherwise unchanged

Example 3: Python + LangGraph — agent mode

create_agent (in langchain.agents) takes a model, tools, and system_prompt — a natural fit for agent mode. The instructions string replaces the hardcoded system_prompt argument. Tools remain hardcoded for now (Stage 3 will move them into the config too).

API note. Use from langchain.agents import create_agent. The earlier from langgraph.prebuilt import create_react_agent is deprecated in LangGraph 1.0 and removed in 2.0. Same return shape; the only rename you'll feel at the call site is prompt= → system_prompt=. Node.js still uses createReactAgent from @langchain/langgraph/prebuilt — no JS deprecation.

Before

from langchain_openai import ChatOpenAI
from langchain.agents import create_agent
from my_tools import search_kb, calculator

llm = ChatOpenAI(model="gpt-4o", temperature=0.3)

agent = create_agent(
    llm,
    [search_kb, calculator],
    system_prompt=(
        "You are a technical support assistant. Use the search_kb tool to look up "
        "documentation, and the calculator tool for math. Always cite sources."
    ),
)

def run_support(user_question: str) -> str:
    result = agent.invoke({"messages": [{"role": "user", "content": user_question}]})
    return result["messages"][-1].content

After

import ldclient
from ldclient import Context
from ldclient.config import Config
from ldai.client import LDAIClient, AIAgentConfigDefault, ModelConfig, ProviderConfig
from ldai_langchain import create_langchain_model
from langchain.agents import create_agent
from my_tools import search_kb, calculator

ldclient.set_config(Config(os.environ["LD_SDK_KEY"]))
ai_client = LDAIClient(ldclient.get())

FALLBACK = AIAgentConfigDefault(
    enabled=True,
    model=ModelConfig(name="gpt-4o", parameters={"temperature": 0.3}),
    provider=ProviderConfig(name="openai"),
    instructions=(
        "You are a technical support assistant. Use the search_kb tool to look up "
        "documentation, and the calculator tool for math. Always cite sources."
    ),
)

def run_support(user_id: str, user_question: str) -> str:
    context = Context.builder(user_id).kind("user").build()
    config = ai_client.agent_config("support-agent", context, FALLBACK)

    if not config.enabled:
        return ""

    # create_langchain_model forwards every variation parameter. Do NOT hand-roll
    # ChatOpenAI(model=...) — it drops unnamed parameters silently.
    llm = create_langchain_model(config)

    agent = create_agent(
        llm,
        [search_kb, calculator],          # Stage 3 will replace this with config.tools loader
        system_prompt=config.instructions,
    )

    result = agent.invoke({"messages": [{"role": "user", "content": user_question}]})
    return result["messages"][-1].content

What changed

  • agent_config() is called instead of completion_config() because the framework expects an instructions string
  • FALLBACK is an AIAgentConfigDefault (note the different type — same fields as completion except instructions instead of messages)
  • Model construction goes through create_langchain_model(config) from the ldai_langchain helper package — forwards every variation parameter. The alternative of hand-rolling ChatOpenAI(model=config.model.name, temperature=...) would silently drop every parameter not explicitly named.
  • create_agent(..., system_prompt=...) reads from config.instructions
  • Tool list is still hardcoded — Stage 3 handles that move (see agent-mode-frameworks.md for the tool-factory pattern that closes over per-run config)
  • Stage 4 will add a run-scoped tracker (mint in a setup_run entry node, consume in call_model and finalize) — see agent-mode-frameworks.md § Custom StateGraph for the full architecture
  • Provider-side logic (LangGraph, ReAct loop) is unchanged

Rules of thumb across all three examples

  1. Nothing is added to the business logic. The provider call, the framework call, the return shape — all unchanged. Only the source of model/prompt/params moves.
  2. Fallback is built from the values you removed. Full rules at fallback-defaults-pattern.md § Critical rules.
  3. Build a Context per request. The context carries targeting inputs — user ID, plan tier, region, whatever the rollout is keyed on. Reuse the same context the app already uses for feature flag evaluation if one exists.
  4. Always check config.enabled. Even a successful completion_config call can return a disabled config (if the variation is turned off in LaunchDarkly). The disabled path should not call the provider.
  5. Do not cache the config object across requests. Call completion_config / agent_config inside the request handler so LaunchDarkly can re-evaluate targeting per call. One LDAIClient instance, many completion_config calls.

Source: SKILL.md on GitHub

No alerts2d3 checks · Risk SAFE
  • Gen Agent Trust Hub2d

    A legitimate orchestration skill for migrating hardcoded AI prompts to LaunchDarkly AgentControl. It follows a structured, human-in-the-loop workflow with clear instructions and fallback patterns.

  • Socket2d

    No alerts

  • Snyk2d

    Risk: LOW · No issues

Signed by skilld at f1bea2f. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 days ago.

Activeupdated 2 weeks ago
compatibility
Requires the remotely hosted LaunchDarkly MCP server
metadata
{
  "author": "launchdarkly",
  "version": "0.1.0"
}
  • Python
  • TypeScript
  • launchdarkly
  • migration
  • llm
  • prompt-management
  • agent
  • openai
  • anthropic
  • bedrock

README badge

README badge for launchdarkly/agent-skills/migrate

Guides you through migrating a hardcoded LLM application to LaunchDarkly AgentControl in five stages: audit the code, wrap the call, move the tools, add tracking, attach evaluators. Targets Python and Node.js applications calling OpenAI, Anthropic, Bedrock, Gemini, or agent frameworks like LangGraph and CrewAI.

Generated from the current SKILL.md.

Does this skill work with my LLM provider?
The skill has worked examples for OpenAI, Anthropic, Bedrock, Gemini, and Strands. For LangChain, LangGraph, CrewAI, and custom ReAct loops, coverage varies by language — Python has deep examples; Node.js has partial coverage with ⚠️ annotations. See the coverage table in the SKILL.md for your framework.
What if my app uses a framework not listed in the coverage table?
Apply the three framework-agnostic invariants: one `agent_config` per turn, one tracker per turn, and fire at-most-once methods once at turn end. These rules apply regardless of framework; see agent-mode-frameworks.md § Framework-agnostic invariants.
Can I skip stages or do them out of order?
Stages must be ordered — audit, wrap, tools, tracking, then evaluators. You can ship after Stage 4 (tracking) is complete; Stage 5 (evaluators) is optional. Skipping ahead produces configs without traffic or metrics without context.
Does this skill handle streaming responses?
Streaming support is delegated to the built-in-metrics skill's streaming-tracking.md reference. Use `trackStreamMetricsOf` plus manual TTFT tracking.
What environment setup is required?
You need `LD_SDK_KEY` (server-side SDK key) set, the LaunchDarkly MCP server configured, and the target application already calling an LLM provider with hardcoded prompts and model values. Check the SDK CHANGELOG before starting to ensure no breaking changes post-date this skill.

Generated from the current SKILL.md. These answers refresh after source changes.