AWS Builder Center

Before I Build My First Bedrock Agent: 5 Rules I Locked Down First

My contract for a Bedrock agent before writing the happy path: typed output, guardrails outside the prompt, a per-call token budget, a shape test, and bounded retries.

I opened the Bedrock docs three times and closed the tab three times. Not because it was hard. Because I already knew how this ends: I ship a prompt that works on a Tuesday, it breaks on a Wednesday, and I lose a day debugging a str that used to be a dict.
I have not built the agent yet. This is the contract I wrote before opening boto3, so the happy path is not the only path I know how to walk.
Context, so you can judge how much of this is engineering and how much is scar tissue: I run three client sites โ€” a nutrition clinic, a diet and training centre with an admin dashboard, a phone-accessory shop โ€” on Railway and Vercel. Not AWS. I have $30 of AWS student credits and one rule: if an EC2 instance, an RDS database or a NAT gateway is not needed today, I stop it or I delete it. I write software for a living. I am a beginner at this specific thing.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
user question
|
v
[ my FastAPI endpoint ] --> log input, log tokens, log cost
|
v
[ bedrock.converse ] temperature 0, maxTokens capped, trace ON
| ^
| | toolChoice pins ONE tool
v |
[ I validate the tool args -> I run the tool ]
|
v
[ bedrock.converse ] second turn, toolResult attached
|
v
typed JSON -> my pydantic model -> my Next.js UI
|
v
[ nothing above this line is optional ]

1. ๐Ÿงต Typed output, not parsed prose

Every time a model hands me JSON I asked for in a prompt, I remember a month of CSS bugs where a class existed and nothing ever used it. Same bug with a nicer name: defined but never wired.
"Reply with JSON only" does not mean JSON. It means JSON wrapped in a markdown fence, or JSON preceded by "Sure!", or JSON with a trailing comma. So the output contract lives in a model, and a response that fails validation is an exception I handle โ€” not a string I clean up on a Friday afternoon.
1
2
3
4
5
6
7
8
9
10
11
12
13
from pydantic import BaseModel, ValidationError

class AgentReply(BaseModel):
invoice_id: str
status: str
amount_cents: int
model_config = {"extra": "forbid"} # extra keys mean the schema drifted -> fail loudly

try:
reply = AgentReply.model_validate(raw_json)
except ValidationError:
# surface it in the log with the raw payload attached, never silently coerce
raise

2. ๐Ÿ”ง The tool list is code, not a suggestion

The model does not get to decide whether it uses a tool. toolChoice decides. Without it I am writing a program where a percentage decides which branch runs, and I have shipped enough of those already.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import boto3

MODEL_ID = "TODO: paste an id from the Bedrock supported-model list" # console > Bedrock > Model list
# never hardcode from memory -> model ids get retired, and I have debugged a 404 from an
# id I was "sure" was right

TOOLS = [{
"toolSpec": {
"name": "lookup_invoice",
"description": "Look up one invoice by id. Returns a not_found error object if unknown.",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"invoice_id": {"type": "string", "pattern": "^INV-[0-9]{4}$"},
},
"required": ["invoice_id"],
"additionalProperties": False, # stops the model inventing a "customer_id" field
}
},
}
}]

client = boto3.client("bedrock-runtime", region_name="eu-west-1")

response = client.converse(
modelId=MODEL_ID,
messages=[{"role": "user", "content": [{"text": "Status of INV-0042."}]}],
toolConfig={
"tools": TOOLS,
"toolChoice": {"tool": {"name": "lookup_invoice"}}, # pinned -> no "maybe I'll just guess"
},
inferenceConfig={"maxTokens": 512, "temperature": 0},
guardrailConfig={
"guardrailIdentifier": "TODO: your guardrail id",
"guardrailVersion": "1",
"trace": "ENABLED", # so when it goes wrong I get the reasoning trace, not a shrug
},
)
response["stopReason"] being "tool_use" is the normal path, not an error. I validate response["output"]["message"]["content"][0]["toolUse"]["input"] against the same schema, run the tool, and send the result back as a toolResult block in the next turn. If I skip that second turn the agent just talks to itself, which is a sentence I would like to avoid saying about my own code.

3. ๐ŸงŠ Temperature 0 is a feature, maxTokens is a firewall

I want a lookup agent, not a creative writing partner. Zero temperature, and a hard maxTokens โ€” because the cheapest way to survive a runaway loop is to make each step unable to run away. That cap also protects the budget, which brings me to the boring section.

4. ๐Ÿ›ก๏ธ Guardrails in the first commit

Not after the first incident. My incidents are always cheaper to prevent than to explain, and I have spent one whole Sunday reading my own Supabase logs because a policy was missing. guardrailConfig is four lines. There is no version of me that has an excuse for putting it in later, only the version that was in a hurry, and that version never wins.

5. ๐Ÿงช The prompt is not the test suite

I score myself 0% at testing, so I made this a rule instead of an aspiration. Ten questions written down before I write the agent, with the shape of the answer I expect. Run all ten after every change. If the shape moves, I stop and read the diff. A prompt is code with worse error messages.
Where each rule actually lives:
ConcernLiving in the promptEnforced in code
Output shape"Reply with JSON only"pydantic model, extra: forbid
Tool usage"Use the invoice tool when you can"toolChoice pinning the tool
Bad arguments"Only send valid invoice ids"pattern + additionalProperties: false
Spend"Be concise"maxTokens + budget alarm
Regression"Don't change the tone"ten fixed inputs, re-run every commit

๐Ÿ’ธ The $30 version of cost control

The budget alarm goes on before the first converse call. Not after I understand the dashboard โ€” after I understand that I do not understand the dashboard. Bill the guardrail, bill the agent, and the alarm email is the whole safety system.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 1. turn on model invocation logging FIRST -> every call lands in CloudWatch Logs
aws bedrock put-model-invocation-logging-configuration \
--logging-config '{
"cloudWatchConfig": {
"logGroupName": "/aws/bedrock/agent",
"roleArn": "arn:aws:iam::123456789012:role/bedrock-log-writer"
},
"textDataDeliveryEnabled": true
}'

# 2. the real cap, in dollars
aws budgets create-budget --budget-name agent-cap \
--budget-type COST --time-unit MONTHLY \
--budget-limit Amount=10 USD

# 3. then alarm on the token metric
aws cloudwatch put-metric-alarm --alarm-name bedrock-token-spike \
--namespace AWS/Bedrock --metric-name OutputTokenCount \
--dimensions Name=ModelId,Value=$MODEL_ID \
--statistic Sum --period 300 --evaluation-periods 1 \
--threshold 20000 --comparison-operator GreaterThanThreshold
On top of that: log response["usage"] into a table on every call, so I can see the cost per question instead of guessing. And keep the stable prefix stable โ€” system prompt and tool definitions go in front, unchanged, so models that support prompt caching can reuse them. Every time I "just tweaked one clause", the cache went cold and I paid full price for the same text. Put the instructions in a versioned file. Diff them like code.

๐Ÿชž The mistake I already know I am going to make

I will retry a throttled call and run the tool twice. Not because I am careless โ€” because the retry is two lines and the failure mode is invisible. The first invoice lookup succeeds, the response times out on the way back, and I resend the same turn. My lookup_invoice now has an invoice twice, or a notification sent twice, and my logs show two clean successes.
Prevention: every tool call carries an idempotency key I generate, and a turn retries at most twice with jittered backoff. That is written in the README before the first line of agent code, not discovered on a Sunday. I have rebuilt this whole contract in my head three times already โ€” once when a Prisma version drifted under me, once for a rate limiter that leaked memory, once for a Map I shared across every endpoint because sharing was easier. The prevention is always the same: make the bad thing structurally impossible before the first commit, not carefully avoided after the third incident.

๐Ÿšง Where this leaves me

I have not built this yet. When I do, the README will link back to this post, and it will either prove these five rules were right or show which one I broke first. My bet is on the second one, the tool choice, because that is the one that looks like it does not need writing down.
If it turns out I was wrong, I will edit this post and say so. That part I am not worried about.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article