DEV Community

Maruchin Tech
Maruchin Tech

Posted on

Stop Hardcoding Prompts: Prompt Management and Prompt Flows on Amazon Bedrock (Hands-On)

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)
Enter fullscreen mode Exit fullscreen mode

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
  • jq and python3 (with boto3) 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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}}
  }]'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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'
Enter fullscreen mode Exit fullscreen mode

Two details worth noticing:

  • Data edges vs. conditional edges. The question flows to all three prompt nodes over Data connections, but TechAnswer and GeneralAnswer only execute when the Conditional edge 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
Enter fullscreen mode Exit fullscreen mode

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?")
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)