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

> Source: <https://dev.to/maruchin_tech_555/stop-hardcoding-prompts-prompt-management-and-prompt-flows-on-amazon-bedrock-hands-on-3pad>
> Published: 2026-08-21 17:16:04+00:00

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="<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.

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:

``` python
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"])
```

Delete child resources before parents:

``` php
# 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
```

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](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/](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/](https://www.udemy.com/user/maruchin-tech-2/)

🎫 Monthly discount coupons:

[https://www.youtube.com/@MaruchinTech-cloud/community](https://www.youtube.com/@MaruchinTech-cloud/community)
