Stop Hardcoding Prompts: Prompt Management and Prompt Flows on Amazon Bedrock (Hands-On) Amazon Bedrock's Prompt Management and Prompt Flows features let developers manage prompts as versioned AWS resources and orchestrate multi-step LLM pipelines without glue code. A hands-on guide demonstrates building a customer-support pipeline that classifies inquiries, routes them, and answers with the appropriate model, using Amazon Nova Micro for cheap tasks and Claude for technical questions. The approach reduces prompt sprawl and enables cost optimization by routing cheap traffic away from expensive models. If your team is building LLM applications, your prompts are probably scattered across application code as string literals. Changing a single word means a code change, a review, and a redeploy — and nobody can tell which prompt version is actually running in production. As LLM apps move from prototypes to production systems, prompt sprawl becomes a real operational problem. In this hands-on, we'll fix that with two Amazon Bedrock features — Prompt Management and Prompt Flows — by building a customer-support pipeline that classifies an inquiry, routes it, and answers it with the right model. Entirely from the AWS CLI, every command included. Prefer video? This entire hands-on is also on YouTube: Customer inquiry │ ▼ Classifier prompt ← Amazon Nova Micro cheap, temperature 0 │ ▼ Router ── TECH ──▶ Tech answer prompt ← Claude accurate │ └── everything else ──▶ General answer prompt ← Nova Micro cheap Note the cost design: the classification and general answers run on Amazon Nova Micro fast and inexpensive , while only genuinely technical questions reach Claude. Routing cheap traffic away from your most expensive model is one of the most practical cost optimizations in production LLM systems. Prompt Management turns a prompt into a first-class AWS resource . Instead of a string in your code, a prompt becomes an object with its own ARN, and it carries everything needed to run it: {{question}} — filled in at invocation time modelId The consequences matter more than the mechanics. Your application code no longer contains prompt text at all — it references a prompt ARN. Prompt engineers can iterate on the DRAFT without touching application code, while production keeps calling a pinned version. Swapping the underlying model for a prompt is a configuration change, not a code change. Prompt Flows is Bedrock's serverless orchestration layer for multi-step LLM pipelines . A flow is a graph of nodes — Input, Prompt, Condition, Output, and others — connected by data and conditional edges. The flow engine executes the graph for you: no Lambda glue code, no Step Functions state machine to maintain. Flows also get the same lifecycle treatment as prompts: you version a flow and expose it through aliases e.g. a prod alias pinned to version 1 , so you can rewire the pipeline behind a stable identifier without redeploying callers. Together, the two features give you something teams usually hand-roll: a prompt registry with versioning, plus a managed execution engine with routing — both callable through standard AWS APIs and IAM. us-east-1 jq and python3 with boto3 installedBedrock models are updated frequently — check the console and use the latest versions. export CLAUDE MODEL="us.anthropic.claude-haiku-4-5-20251001-v1:0" export NOVA MODEL="us.amazon.nova-micro-v1:0" export REGION=us-east-1 First, the classifier. Temperature 0 and a 10-token cap: we want a deterministic label, nothing else. CLASSIFIER ID=$ aws bedrock-agent create-prompt --region $REGION \ --name customer-classifier \ --description "Classify customer inquiries as TECH or GENERAL" \ --default-variant v1 \ --variants ' { "name":"v1", "templateType":"TEXT", "templateConfiguration":{"text":{ "text":"Classify the following customer inquiry into exactly one of the following. Respond with the label only and nothing else.\n\n- TECH: technical product issues, configuration, errors, how to use the product\n- GENERAL: pricing, contracts, business hours, and other general questions\n\nInquiry: {{question}}\n\nLabel:", "inputVariables": {"name":"question"} }}, "modelId":"'"$NOVA MODEL"'", "inferenceConfiguration":{"text":{"temperature":0.0,"maxTokens":10}} } ' \ --query 'id' --output text echo "CLASSIFIER ID=$CLASSIFIER ID" Next, the technical answerer — this one runs on Claude, with a lower temperature for accuracy: TECH ID=$ aws bedrock-agent create-prompt --region $REGION \ --name customer-tech-answer \ --description "Answer technical inquiries with Claude" \ --default-variant v1 \ --variants ' { "name":"v1", "templateType":"TEXT", "templateConfiguration":{"text":{ "text":"You are a technical support representative for our products. Answer the following technical inquiry concisely and accurately.\n\nInquiry: {{question}}\n\nAnswer:", "inputVariables": {"name":"question"} }}, "modelId":"'"$CLAUDE MODEL"'", "inferenceConfiguration":{"text":{"temperature":0.3,"maxTokens":400}} } ' \ --query 'id' --output text echo "TECH ID=$TECH ID" And the general answerer, back on Nova Micro: GENERAL ID=$ aws bedrock-agent create-prompt --region $REGION \ --name customer-general-answer \ --description "Answer general inquiries with Nova" \ --default-variant v1 \ --variants ' { "name":"v1", "templateType":"TEXT", "templateConfiguration":{"text":{ "text":"You are a customer support representative for our company. Answer the following general inquiry politely and helpfully.\n\nInquiry: {{question}}\n\nAnswer:", "inputVariables": {"name":"question"} }}, "modelId":"'"$NOVA MODEL"'", "inferenceConfiguration":{"text":{"temperature":0.5,"maxTokens":400}} } ' \ --query 'id' --output text echo "GENERAL ID=$GENERAL ID" Three prompts, three independent lifecycles, two different models — and not a single line of application code yet. Here's the detail that surprises most people: the Converse API accepts a prompt ARN as the model ID . The prompt brings its own model and inference settings; you only supply the variables. ACCOUNT ID=$ aws sts get-caller-identity --query Account --output text CLASSIFIER ARN="arn:aws:bedrock:$REGION:$ACCOUNT ID:prompt/$CLASSIFIER ID" aws bedrock-runtime converse --region $REGION \ --model-id "$CLASSIFIER ARN" \ --prompt-variables '{"question":{"text":"I cannot log in. I want to reset my password."}}' \ | jq -r '.output.message.content 0 .text' Expected output: TECH aws bedrock-runtime converse --region $REGION \ --model-id "$CLASSIFIER ARN" \ --prompt-variables '{"question":{"text":"How much is the monthly fee?"}}' \ | jq -r '.output.message.content 0 .text' Expected output: GENERAL Every prompt has an editable DRAFT. create-prompt-version freezes the current DRAFT into an immutable numbered version: CLASSIFIER V1=$ aws bedrock-agent create-prompt-version --region $REGION \ --prompt-identifier $CLASSIFIER ID \ --description "Production version" \ --query version --output text echo "CLASSIFIER V1=$CLASSIFIER V1" - 1 TECH V1=$ aws bedrock-agent create-prompt-version --region $REGION \ --prompt-identifier $TECH ID --query version --output text GENERAL V1=$ aws bedrock-agent create-prompt-version --region $REGION \ --prompt-identifier $GENERAL ID --query version --output text echo "TECH V1=$TECH V1 GENERAL V1=$GENERAL V1" The ARN convention does the rest. Append :1 for the pinned production version; omit the suffix to hit the DRAFT: Production: pinned to version 1 aws bedrock-runtime converse --region $REGION \ --model-id "$CLASSIFIER ARN:$CLASSIFIER V1" \ --prompt-variables '{"question":{"text":"I want to reset my password"}}' \ | jq -r '.output.message.content 0 .text' Testing: the DRAFT, with whatever edits are in flight aws bedrock-runtime converse --region $REGION \ --model-id "$CLASSIFIER ARN" \ --prompt-variables '{"question":{"text":"I want to reset my password"}}' \ | jq -r '.output.message.content 0 .text' Let's prove the isolation. Rewrite the DRAFT to add a third label, BILLING : aws bedrock-agent update-prompt --region $REGION \ --prompt-identifier $CLASSIFIER ID \ --name customer-classifier \ --default-variant v1 \ --variants ' { "name":"v1", "templateType":"TEXT", "templateConfiguration":{"text":{ "text":"Classify the following customer inquiry into exactly one of the following. Respond with the label only and nothing else.\n\n- TECH: technical product issues, configuration, errors, how to use the product\n- BILLING: pricing, invoices, payment methods\n- GENERAL: everything else\n\nInquiry: {{question}}\n\nLabel:", "inputVariables": {"name":"question"} }}, "modelId":"'"$NOVA MODEL"'", "inferenceConfiguration":{"text":{"temperature":0.0,"maxTokens":10}} } ' Now ask the same billing question twice: DRAFT — picks up the new behavior aws bedrock-runtime converse --region $REGION \ --model-id "$CLASSIFIER ARN" \ --prompt-variables '{"question":{"text":"How much is the monthly fee?"}}' \ | jq -r '.output.message.content 0 .text' - BILLING Version 1 — production is untouched aws bedrock-runtime converse --region $REGION \ --model-id "$CLASSIFIER ARN:1" \ --prompt-variables '{"question":{"text":"How much is the monthly fee?"}}' \ | jq -r '.output.message.content 0 .text' - GENERAL Same prompt resource, two behaviors, zero risk to production. This is the workflow Prompt Management exists for. Now we wire the three prompts into one pipeline. A flow runs under its own IAM role, which needs permission to invoke models and read the prompts: cat /tmp/flow-trust.json << 'EOF' { "Version":"2012-10-17", "Statement": {"Effect":"Allow","Principal":{"Service":"bedrock.amazonaws.com"},"Action":"sts:AssumeRole"} } EOF FLOW ROLE ARN=$ aws iam create-role \ --role-name bedrock-flow-role \ --assume-role-policy-document file:///tmp/flow-trust.json \ --query 'Role.Arn' --output text cat /tmp/flow-policy.json << 'EOF' { "Version":"2012-10-17", "Statement": {"Effect":"Allow","Action": "bedrock:InvokeModel","bedrock:Converse","bedrock:GetPrompt","bedrock:RenderPrompt","bedrock:GetInferenceProfile" ,"Resource":" "} } EOF aws iam put-role-policy \ --role-name bedrock-flow-role \ --policy-name bedrock-flow-inline \ --policy-document file:///tmp/flow-policy.json echo "FLOW ROLE ARN=$FLOW ROLE ARN" sleep 10 wait for the role to propagate For production, scope Resource down to the specific prompt and model ARNs. A flow definition has two halves: nodes the boxes and connections the arrows . Ours has an Input node, the three Prompt nodes referencing the pinned production versions of our prompts, a Condition node for routing, and two Output nodes: CLASSIFIER PROD ARN="$CLASSIFIER ARN:$CLASSIFIER V1" TECH PROD ARN="arn:aws:bedrock:$REGION:$ACCOUNT ID:prompt/$TECH ID:$TECH V1" GENERAL PROD ARN="arn:aws:bedrock:$REGION:$ACCOUNT ID:prompt/$GENERAL ID:$GENERAL V1" jq -n \ --arg cls "$CLASSIFIER PROD ARN" \ --arg tech "$TECH PROD ARN" \ --arg gen "$GENERAL PROD ARN" \ '{ nodes: { name:"FlowInputNode", type:"Input", configuration:{input:{}}, outputs: {name:"document", type:"String"} }, { name:"Classifier", type:"Prompt", configuration:{prompt:{sourceConfiguration:{resource:{promptArn:$cls}}}}, inputs: {name:"question", type:"String", expression:"$.data"} , outputs: {name:"modelCompletion", type:"String"} }, { name:"Router", type:"Condition", configuration:{condition:{conditions: {name:"isTech", expression:"classification == \"TECH\""}, {name:"default"} }}, inputs: {name:"classification", type:"String", expression:"$.data"} }, { name:"TechAnswer", type:"Prompt", configuration:{prompt:{sourceConfiguration:{resource:{promptArn:$tech}}}}, inputs: {name:"question", type:"String", expression:"$.data"} , outputs: {name:"modelCompletion", type:"String"} }, { name:"GeneralAnswer", type:"Prompt", configuration:{prompt:{sourceConfiguration:{resource:{promptArn:$gen}}}}, inputs: {name:"question", type:"String", expression:"$.data"} , outputs: {name:"modelCompletion", type:"String"} }, { name:"TechOutputNode", type:"Output", configuration:{output:{}}, inputs: {name:"document", type:"String", expression:"$.data"} }, { name:"GeneralOutputNode", type:"Output", configuration:{output:{}}, inputs: {name:"document", type:"String", expression:"$.data"} } , connections: {name:"i2c", source:"FlowInputNode", target:"Classifier", type:"Data", configuration:{data:{sourceOutput:"document", targetInput:"question"}}}, {name:"i2t", source:"FlowInputNode", target:"TechAnswer", type:"Data", configuration:{data:{sourceOutput:"document", targetInput:"question"}}}, {name:"i2g", source:"FlowInputNode", target:"GeneralAnswer", type:"Data", configuration:{data:{sourceOutput:"document", targetInput:"question"}}}, {name:"c2r", source:"Classifier", target:"Router", type:"Data", configuration:{data:{sourceOutput:"modelCompletion", targetInput:"classification"}}}, {name:"r2t", source:"Router", target:"TechAnswer", type:"Conditional", configuration:{conditional:{condition:"isTech"}}}, {name:"r2g", source:"Router", target:"GeneralAnswer", type:"Conditional", configuration:{conditional:{condition:"default"}}}, {name:"t2o", source:"TechAnswer", target:"TechOutputNode", type:"Data", configuration:{data:{sourceOutput:"modelCompletion", targetInput:"document"}}}, {name:"g2o", source:"GeneralAnswer", target:"GeneralOutputNode", type:"Data", configuration:{data:{sourceOutput:"modelCompletion", targetInput:"document"}}} }' /tmp/flow-def.json cat /tmp/flow-def.json | jq '.nodes .name, .connections .name' Two details worth noticing: Data connections, but TechAnswer and GeneralAnswer only Conditional edge from the Router fires. The Condition node gates execution, not data. classification == "TECH" compares the classifier's raw output — which is exactly why we forced the classifier to answer with the label only. FLOW ID=$ aws bedrock-agent create-flow --region $REGION \ --name customer-support-flow \ --description "Classify - route - answer" \ --execution-role-arn "$FLOW ROLE ARN" \ --definition file:///tmp/flow-def.json \ --query 'id' --output text echo "FLOW ID=$FLOW ID" Compile the flow into an executable state aws bedrock-agent prepare-flow --region $REGION \ --flow-identifier $FLOW ID invoke flow streams events, so we test from Python. TSTALIASID is the built-in alias that always points at the working draft of the flow: python import boto3 client = boto3.client "bedrock-agent-runtime", region name="us-east-1" def ask question : resp = client.invoke flow flowIdentifier="