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:
What we'll build
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.
What is Prompt Management?
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:
-
A template with variables — e.g.
{{question}}— filled in at invocation time -
A bound model — each prompt knows which model it runs on (
modelId) - Inference configuration — temperature, max tokens, etc., stored with the prompt
- Versions — an editable DRAFT plus immutable numbered snapshots
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.
What is Prompt Flows?
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.
Prerequisites
- AWS CLI v2 configured, with access to Bedrock in
us-east-1 - Model access enabled for the Claude and Nova models in the Bedrock console
-
jqandpython3(withboto3) installed
Step 1: Prompt Management
Set up environment variables
Bedrock 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
Create the prompts
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.
Invoke a managed prompt directly
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
Versioning: the DRAFT / pinned-version workflow
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.
Step 2: Prompt Flows
Now we wire the three prompts into one pipeline.
Create the execution role
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.)
Define the flow as JSON
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 edges vs. conditional edges. The question flows to all three prompt nodes over
Dataconnections, butTechAnswerandGeneralAnsweronly execute when theConditionaledge from the Router fires. The Condition node gates execution, not data. -
The Router's expression (
classification == "TECH") compares the classifier's raw output — which is exactly why we forced the classifier to answer with the label only.
Create, prepare, and test the flow
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:
import boto3
client = boto3.client("bedrock-agent-runtime", region_name="us-east-1")
def ask(question):
resp = client.invoke_flow(
flowIdentifier="<FLOW_ID>",
flowAliasIdentifier="TSTALIASID",
inputs=[{
"nodeName": "FlowInputNode",
"nodeOutputName": "document",
"content": {"document": question}
}]
)
for event in resp["responseStream"]:
if "flowOutputEvent" in event:
out = event["flowOutputEvent"]
print(f" [Output node: {out['nodeName']}]")
print(" " + out["content"]["document"].replace("\n", "\n "))
print("=== Technical question ===")
ask("The app crashes as soon as I launch it. What could be the cause?")
print()
print("=== General question ===")
ask("What are the support center's business hours?")
The technical question comes back from TechOutputNode (answered by Claude), the business-hours question from GeneralOutputNode (answered by Nova). The routing works.
Flow versions and aliases
Flows version exactly like prompts — and aliases give callers a stable name:
FLOW_V1=$(aws bedrock-agent create-flow-version --region $REGION \
--flow-identifier $FLOW_ID \
--description "Production release v1" \
--query version --output text)
echo "FLOW_V1=$FLOW_V1" # -> 1
PROD_ALIAS_ID=$(aws bedrock-agent create-flow-alias --region $REGION \
--flow-identifier $FLOW_ID \
--name prod \
--routing-configuration '[{"flowVersion":"'"$FLOW_V1"'"}]' \
--query id --output text)
echo "PROD_ALIAS_ID=$PROD_ALIAS_ID"
Production callers use the prod alias and never change, even when you later repoint the alias at version 2:
import boto3
client = boto3.client("bedrock-agent-runtime", region_name="us-east-1")
resp = client.invoke_flow(
flowIdentifier="<FLOW_ID>",
flowAliasIdentifier="<PROD_ALIAS_ID>",
inputs=[{
"nodeName": "FlowInputNode",
"nodeOutputName": "document",
"content": {"document": "How do I get a receipt issued?"}
}]
)
for event in resp["responseStream"]:
if "flowOutputEvent" in event:
out = event["flowOutputEvent"]
print(f"[{out['nodeName']}]")
print(out["content"]["document"])
Cleanup
Delete child resources before parents:
# Flow (alias -> version -> flow)
aws bedrock-agent delete-flow-alias --region $REGION \
--flow-identifier $FLOW_ID --alias-identifier $PROD_ALIAS_ID
aws bedrock-agent delete-flow-version --region $REGION \
--flow-identifier $FLOW_ID --flow-version $FLOW_V1
aws bedrock-agent delete-flow --region $REGION \
--flow-identifier $FLOW_ID --skip-resource-in-use-check
# Prompts
for P in $CLASSIFIER_ID $TECH_ID $GENERAL_ID; do
aws bedrock-agent delete-prompt --region $REGION --prompt-identifier $P
done
# IAM
aws iam delete-role-policy --role-name bedrock-flow-role --policy-name bedrock-flow-inline
aws iam delete-role --role-name bedrock-flow-role
Wrapping up
Prompt Management and Prompt Flows are not just convenience features — they move your prompts and your LLM pipeline out of application code and into versioned, IAM-governed AWS resources. The DRAFT/version split gives prompt engineering a safe test-in-production workflow, and flow aliases let you re-architect a pipeline behind a stable endpoint. If you run LLM workloads on AWS, I recommend validating this pattern in your own environment.
About the author
Maruchin Tech — 12x AWS Certified | Cloud & AI for manufacturing and supply chain (AWS / Google Cloud / Azure) | Udemy instructor (100K+ students)
🎥 Video version of this hands-on:
https://youtu.be/iyK_xK3G-i0
📚 Full course — AWS Certified Generative AI Developer Professional (AIP-C01) Exam Prep:
https://www.udemy.com/course/aws-certified-generative-ai-developer-professional-exam-prep/
👨🏫 All my courses (Udemy profile):
https://www.udemy.com/user/maruchin-tech-2/
🎫 Monthly discount coupons:
https://www.youtube.com/@MaruchinTech-cloud/community
Top comments (0)