AWS Builder Center
Strands Decider 2B: a hands-on a 13-year-old can follow

Strands Decider 2B: a hands-on a 13-year-old can follow

Run AWS's new 2B decision model on a laptop, find its blind spots, then tune it into a gatekeeper that stops an AI agent from guessing. All code included.

Cloud Engineer
I asked an AI agent "What's the weather?" without saying where. It decided I meant Seattle and went straight to its weather tool. A 2-billion-parameter model running on my laptop caught that guess and sent the agent back to ask me which city I meant. But it only worked after I changed two things the official sample does.
The model is Strands Decider 2B, which AWS released on October 1, 2026. It cannot write a single sentence. You give it some text and a list of options, and it gives you a probability for each option. This post is a hands-on that a middle school student can follow. It starts from installing the model and ends with that gatekeeper. Every script is included, so copying them in order should reproduce my results.

A model that only answers multiple-choice questions

Chatbots like Claude are students taking an essay test. They can write anything, but writing takes time and the wording changes every time. Strands Decider 2B is a student taking a multiple-choice test. It always picks one of the options you wrote and puts a probability next to each of them.
That sounds limited, but AI agents make lots of tiny multiple-choice decisions: which team gets this ticket, whether it's OK to call a tool right now, whether a request is safe. The Decider handles those on your own machine, without asking a big model to write an essay each time. The project calls these "system one" models, borrowing the name for fast, gut-feeling thinking.
If you're curious how it works inside (skip this paragraph if not): it starts from a regular text model, Qwen3.5-2B-Base, with about 1.9 billion parameters. The team removed the part that predicts the next word and attached a small part that scores each option you supply, all in one pass.

What you need

You need a Mac with Apple silicon (M1, M2, M3 and so on) or a Linux machine with an NVIDIA GPU, about 10 GB of free disk space, and an internet connection. The project says it also runs on a plain CPU, just more slowly. The commands below are for macOS and Linux; I did not try Windows. Only step 5-2 and step 6 need an AWS account.
I tested everything on an Apple M1 Mac with 16 GB of memory, Python 3.12, strands-decider 0.1.0, and strands-agents 1.57.1, on October 2, 2026.

Set up the Decider

uv is a tool that installs Python and libraries for you. If you don't have it yet, install it by following the uv installation guide , then open a terminal.
Create a working folder with its own Python environment and install the Decider:
1
2
3
4
5
mkdir strands-decider-handson
cd strands-decider-handson
uv venv -p 3.12 .venv
source .venv/bin/activate
uv pip install strands-decider
The source .venv/bin/activate line puts you inside this project's Python environment. Run it again whenever you open a new terminal.
Now ask a first question from the command line. The first run downloads the model, which took about 85 seconds and about 9 GB of disk on my Mac.
1
2
3
strands-decider ask StrandsAgents/strands-decider-2B-hobson-v19 \
--state "Help! My payouts have been failing for 3 days!" \
--choice "Which team should handle this?=billing,sales,retail"
1
2
3
4
choice_0 -> billing (confidence 0.766)
billing 0.844
retail 0.093
sales 0.063
Billing wins with 84%. The "confidence" number on the first line is the top probability rescaled for the number of options; I'll use the plain probabilities from here on.
Loading the model for every question is slow, so open a second terminal, start the Decider as a server, and leave that terminal open:
1
2
3
cd strands-decider-handson
source .venv/bin/activate
strands-decider serve StrandsAgents/strands-decider-2B-hobson-v19 --port 8099
It's ready when the log says Uvicorn is running on port 8099.
Back in the first terminal, save the following as easy.py in your working folder. It's a small helper that sends questions to the server, and every other script imports it.
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
"""A tiny helper for asking Strands Decider questions.

Start the server in another terminal first:
strands-decider serve StrandsAgents/strands-decider-2B-hobson-v19 --port 8099
"""


import json
import os
import urllib.request

# Where the server is running. Change it with STRANDS_DECIDER_HOST if you use another port.
HOST = os.environ.get("STRANDS_DECIDER_HOST", "127.0.0.1:8099")
URL = "http://" + HOST + "/v1/systemone"

def ask(state, questions):
body = json.dumps({"state": state, "questions": questions}).encode()
req = urllib.request.Request(URL, data=body, headers={"content-type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as res:
return json.loads(res.read())

def yes_no(state, question):
"""Returns the probability (0-1) that the answer is yes."""
r = ask(state, {"q": {"type": "noul", "instructions": question}})
return r["answers"]["q"]["noul"], r["latency_ms"]

def choose(state, question, options):
"""Picks one option. options is a dict of {name: description}."""
r = ask(state, {"q": {"type": "choice", "instructions": question, "criteria": options}})
return r["answers"]["q"], r["latency_ms"]

def score(state, question, levels):
"""Rates on an ordered scale, lowest level first."""
r = ask(state, {"q": {"type": "score", "instructions": question, "criteria": levels}})
return r["answers"]["q"], r["latency_ms"]

def bar(p, width=20):
n = round(p * width)
return "█" * n + "·" * (width - n)

Warm-up: the three question types

Each request has a state (the text to judge) and one or more questions. A question is yes/no (called noul), pick one (choice), or score on a scale (score). Try one of each.
Save this as step1_yes_no.py and run python step1_yes_no.py:
1
2
3
4
5
6
7
8
9
10
11
# Step 1: yes/no
from easy import yes_no, bar

sentences = [
"School lunch today was amazing!",
"Worst day ever. I forgot my umbrella and got soaked.",
"Tomorrow is Monday.",
]
for s in sentences:
p, ms = yes_no(s, "Does the writer sound happy?")
print(f"{bar(p)} yes {p:.0%} ({ms:.0f} ms) {s}")
1
2
3
██████████████████·· yes 89% (1533 ms) School lunch today was amazing!
███················· yes 17% (488 ms) Worst day ever. I forgot my umbrella and got soaked.
██████·············· yes 32% (450 ms) Tomorrow is Monday.
The first call is slow because of a one-time warm-up. After that each answer took about half a second on my M1.
Next, the Decider gets a job at the school help desk. Save this as step2_choose.py and run it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Step 2: pick one option (school help desk router)
from easy import choose, bar

desks = {
"nurse's office": "feeling sick or got hurt",
"teachers' room": "messages for teachers, homework, forgotten items",
"library": "borrow books or research something",
}
requests = [
"I've had a headache and a fever since this morning.",
"I left my math worksheet at home.",
"I want to research dinosaurs for my summer project.",
]
for s in requests:
a, ms = choose(s, "Where should this student go for help?", desks)
print(f"\n{s} -> {a['choice']} ({ms:.0f} ms)")
for name, p in a["probabilities"].items():
print(f" {name:<15} {bar(p)} {p:.0%}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

I've had a headache and a fever since this morning. -> nurse's office (474 ms)
nurse's office ███████████████████· 97%
teachers' room ···················· 2%
library ···················· 1%

I left my math worksheet at home. -> teachers' room (477 ms)
nurse's office ···················· 2%
teachers' room ███████████████████· 96%
library ···················· 2%

I want to research dinosaurs for my summer project. -> library (461 ms)
nurse's office ···················· 1%
teachers' room ···················· 2%
library ███████████████████· 97%
You can rename or add options without retraining anything. Try adding a gym or a front office.
Last, a score. List the levels from lowest to highest. Save this as step3_score.py and run it:
1
2
3
4
5
6
7
8
9
# Step 3: score on an ordered scale
from easy import score

levels = ["very gloomy", "neutral", "very cheerful"] # lowest first
for s in ["School lunch today was amazing!",
"Worst day ever. I forgot my umbrella and got soaked.",
"Tomorrow is Monday."]:
a, ms = score(s, "How cheerful is the writer?", levels)
print(f"score {a['score']:.2f} (0=very gloomy, 2=very cheerful) {s}")
1
2
3
score 1.52 (0=very gloomy, 2=very cheerful) School lunch today was amazing!
score 0.46 (0=very gloomy, 2=very cheerful) Worst day ever. I forgot my umbrella and got soaked.
score 0.97 (0=very gloomy, 2=very cheerful) Tomorrow is Monday.
The project's evaluation notes say the score type is weak, so prefer yes/no or pick-one for anything that matters.

Find its blind spots

A gatekeeper is only useful if you know how it fails. Save this as step4_break_it.py and run it. It runs three small experiments on the help desk.
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
# Step 4: try to break it
from easy import choose

desks = {
"nurse's office": "feeling sick or got hurt",
"teachers' room": "messages for teachers, homework, forgotten items",
"library": "borrow books or research something",
}

def show(title, a):
probs = " ".join(f"{k} {v:.0%}" for k, v in a["probabilities"].items())
print(f"{title} -> {a['choice']} | {probs}")

q = "Where should this student go for help?"
print("[A] Remove the option descriptions. Request: \"I forgot my gym clothes.\"")
show("no descriptions", choose("I forgot my gym clothes.", q, {k: k for k in desks})[0])
show("with descriptions", choose("I forgot my gym clothes.", q, desks)[0])

print("\n[B] A request that fits nothing. Request: \"I'm hungry.\"")
show("3 options", choose("I'm hungry.", q, desks)[0])
show("+ none of these", choose("I'm hungry.", q,
{**desks, "none of these": "does not fit any option above"})[0])

print("\n[C] Flip the question. Request: \"I've had a headache and a fever since this morning.\"")
s = "I've had a headache and a fever since this morning."
show("Where should they go?", choose(s, q, desks)[0])
show("Where must they NOT go?", choose(s, "Which place must this student NOT go to?", desks)[0])
1
2
3
4
5
6
7
8
9
10
11
[A] Remove the option descriptions. Request: "I forgot my gym clothes."
no descriptions -> nurse's office | nurse's office 51% teachers' room 24% library 24%
with descriptions -> teachers' room | nurse's office 7% teachers' room 90% library 3%

[B] A request that fits nothing. Request: "I'm hungry."
3 options -> nurse's office | nurse's office 69% teachers' room 16% library 15%
+ none of these -> none of these | nurse's office 9% teachers' room 2% library 2% none of these 86%

[C] Flip the question. Request: "I've had a headache and a fever since this morning."
Where should they go? -> nurse's office | nurse's office 97% teachers' room 2% library 1%
Where must they NOT go? -> nurse's office | nurse's office 95% teachers' room 3% library 2%
With option names only, "I forgot my gym clothes." went to the nurse's office (51%). Adding a one-line description to each option moved it to the teachers' room (90%). The descriptions carry a lot of the meaning.
"I'm hungry." fits none of the desks, but a multiple-choice test has no "I don't know" box, so it picked the nurse's office anyway (69%). Adding "none of these" fixed it (86%).
Flipping the question to "Which place must this student NOT go to?" still gave the nurse's office (95%). The project documents this: with the state and options fixed, changing only the question gives the same answer about 94% of the time, and questions like "which does NOT fit" are often answered as if they weren't flipped.
So a high percentage doesn't mean a right answer, and the question wording matters less than you'd hope. Both facts shape the gatekeeper.

Build the gatekeeper

The setup: a Strands agent has one weather tool. I gave it a bad habit on purpose: when no city is given, assume Seattle and call the tool immediately. Before the tool runs, the Decider answers one yes/no question about the proposed call, and a few lines of my own code decide whether to let it through.
The idea comes from the official sample, examples/strands/tool_call_intervention.py. My version is simpler in two ways: it asks one question instead of two (the sample also asks whether calling the tool is premature), and it leaves out the one-sentence introduction the sample puts at the top of the text it sends.

5-1: test the question before wiring anything (no AWS)

I started by checking the question on six examples where I know the right answer: three where the agent made up a city and three where the user named it. I compared the sample's general question with a narrower one.
Save this as step5_threshold.py and run it:
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
40
41
# Step 5-1: pick the gatekeeper's question and threshold without an agent (no AWS needed)
import json
from easy import ask

def state(user, city):
"""Put what the user said and the tool call the AI wants to make into one text."""
return (f"--- CONVERSATION ---\nuser: {user}\n\n--- PROPOSED TOOL CALL ---\n"
f"tool: get_weather\narguments: {json.dumps({'location': city})}")

questions = {
"official": {
"type": "noul",
"instructions": "Are the tool's argument values grounded in facts the user actually provided?",
"criteria": {
"true": "every argument value traces back to something the user said",
"false": "an argument value was guessed or invented, not stated by the user",
},
},
"specific": {
"type": "noul",
"instructions": "Did the user name the city that appears in the tool call?",
"criteria": {
"true": "the city in the tool call appears in the user's message",
"false": "the user never mentioned that city",
},
},
}
# (what the user said, city the AI picked, should it pass?)
tests = [
("What's the weather?", "Seattle", False),
("How's the weather today?", "London", False),
("Weather please", "New York", False),
("What's the weather in Chicago?", "Chicago", True),
("What's the weather in Tokyo?", "Tokyo", True),
("Is it raining in Paris now?", "Paris", True),
]
print(f"{'user said':<32}{'city':<10}{'pass?':<7}{'official':>9}{'specific':>10}")
for user, city, ok in tests:
a = ask(state(user, city), questions)["answers"]
print(f"{user:<32}{city:<10}{'yes' if ok else 'no':<7}"
f"{a['official']['noul']:>9.0%}{a['specific']['noul']:>10.0%}")
1
2
3
4
5
6
7
user said city pass? official specific
What's the weather? Seattle no 18% 31%
How's the weather today? London no 18% 32%
Weather please New York no 20% 35%
What's the weather in Chicago? Chicago yes 39% 82%
What's the weather in Tokyo? Tokyo yes 45% 84%
Is it raining in Paris now? Paris yes 30% 82%
The sample's question asks whether the tool's argument values (the inputs the agent passes to the tool) are grounded in what the user said. The sample blocks a call when that probability is below 0.45. In my simplified setup, every row came in under 0.45. Tokyo looks like 45% in the table, but the exact value was 0.446. That means the user's real requests would have been blocked too.
The narrower question, "Did the user name the city that appears in the tool call?", split the rows cleanly. Made-up cities scored 31 to 35%, and named cities scored 82 to 84%. A threshold of 0.65 sits well between the two groups.
This is the first of the two changes. Experiment C showed the Decider doesn't follow a reversed question, but asking about something more specific still made a large difference.

5-2: put it in front of a real agent (needs Amazon Bedrock)

The agent runs on Claude Haiku 4.5 through Amazon Bedrock, which costs a little per call, so younger readers should check with a parent or teacher first. You also need AWS CLI v2 (see the AWS CLI install guide ). The aws login command only exists in recent versions; I used 2.35.15. Sign in by running aws login and approving in the browser window it opens.
Install the agent libraries:
1
uv pip install strands-agents "botocore[crt]"
botocore[crt] lets Python reuse the session from aws login. My first run failed with MissingDependencyException without it.
In the code below, Gatekeeper is an InterventionHandler: Strands calls its before_tool_call method right before any tool runs. The method asks the Decider, then returns Proceed (let it run) or Guide (send feedback and let the agent try again). Save this as step5_gatekeeper.py and run it:
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# Step 5: use the decider as a gatekeeper for an AI agent (needs Amazon Bedrock)
# A shorter version of the official examples/strands/tool_call_intervention.py,
# with the question and threshold changed to match step 5-1
import json
from strands import Agent, tool
from strands.models import BedrockModel
from strands.interventions import Guide, InterventionHandler, Proceed
from easy import ask

QUESTIONS = {
"user_named_city": { # the specific question picked in step 5-1
"type": "noul",
"instructions": "Did the user name the city that appears in the tool call?",
"criteria": {
"true": "the city in the tool call appears in the user's message",
"false": "the user never mentioned that city",
},
},
}
YES = 0.65 # picked from the step 5-1 table. The code decides, not the model

class Gatekeeper(InterventionHandler):
name = "gatekeeper"

def before_tool_call(self, event, **kwargs):
# Show the gatekeeper only what the user said (the agent's own "Let me check Seattle" skews it)
talk = "\n".join(
f"user: {b['text']}"
for m in event.agent.messages if m["role"] == "user"
for b in m.get("content", []) if "text" in b
)
state = (f"--- CONVERSATION ---\n{talk}\n\n--- PROPOSED TOOL CALL ---\n"
f"tool: {event.tool_use['name']}\narguments: {json.dumps(event.tool_use.get('input') or {})}")
r = ask(state, QUESTIONS)
p = r["answers"]["user_named_city"]["noul"]
print(f"[gatekeeper] {event.tool_use['name']}({event.tool_use.get('input')}) user named it {p:.0%} ({r['latency_ms']:.0f} ms)")
if p < YES:
print("[gatekeeper] -> stop! ask the user first")
return Guide(feedback="The user never named that city. Ask which city they mean.")
print("[gatekeeper] -> go ahead")
return Proceed()

@tool
def get_weather(location: str) -> str:
"""Get the current weather for a city.

Args:
location: The city name
"""

return f"It's sunny and 21C in {location}."

SYSTEM_PROMPT = ("You are an eager weather assistant. If no city is given, never ask. "
"Just assume Seattle and call get_weather right away.")

for q in ["What's the weather?", "What's the weather in Chicago?", "Is it raining in Paris now?"]:
print(f"\n========== USER: {q}")
agent = Agent(model=BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0", region_name="us-east-1"), tools=[get_weather],
interventions=[Gatekeeper()], system_prompt=SYSTEM_PROMPT, callback_handler=None)
print(f"Final answer: {agent(q)}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

========== USER: What's the weather?
[gatekeeper] get_weather({'location': 'Seattle'}) user named it 31% (573 ms)
[gatekeeper] -> stop! ask the user first
Final answer: I'd be happy to help with the weather! Which city would you like to know about?

========== USER: What's the weather in Chicago?
[gatekeeper] get_weather({'location': 'Chicago'}) user named it 82% (574 ms)
[gatekeeper] -> go ahead
Final answer: The weather in Chicago is currently **sunny** with a temperature of **21°C** (about 70°F). Nice weather!

========== USER: Is it raining in Paris now?
[gatekeeper] get_weather({'location': 'Paris'}) user named it 81% (558 ms)
[gatekeeper] -> go ahead
Final answer: No, it's not raining in Paris right now! It's actually sunny with a pleasant temperature of 21°C (about 70°F). Great weather in Paris at the moment!
The made-up Seattle call was stopped and the agent asked which city I meant. Chicago and Paris went straight through.

The second change: show only what the user said

My first version passed the whole conversation to the gatekeeper, as the sample does. In my Japanese run of the same test, a city the agent invented then scored 77% and slipped through. Printing the text the gatekeeper was reading showed why:
1
2
3
4
5
6
7
--- CONVERSATION ---
user: 天気どう?
assistant: 東京の天気を確認しますね!

--- PROPOSED TOOL CALL ---
tool: get_weather
arguments: {"location": "東京"}
The user asked only "How's the weather?" (天気どう?). But the agent had already said "Let me check the weather in Tokyo!" (東京の天気を確認しますね!) before calling the tool. The city appeared in the conversation, just not from the user. The question is about the user, so the gatekeeper now reads only the user's messages. That's the filtered loop with the comment at the top of before_tool_call.
Looking back at the whole build, the Decider never decided anything. It produced numbers like 31% and 82%. The rule "stop below 0.65" is an if statement I wrote after looking at six examples, and Guide made the result a follow-up question instead of a flat refusal.

How fast is it?

For a rough comparison, step 6 sends the help desk task to both the Decider and Claude Haiku 4.5 on Amazon Bedrock. Save this as step6_speed.py and run it:
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
# Step 6: race the same task against a text-writing AI (Claude Haiku 4.5, needs Amazon Bedrock)
import time, statistics, boto3
from easy import choose

desks = {"nurse's office": "feeling sick or got hurt",
"teachers' room": "messages for teachers, homework, forgotten items",
"library": "borrow books or research something"}
requests = ["I've had a headache and a fever since this morning.", "I left my math worksheet at home.",
"I want to research dinosaurs for my summer project.", "I forgot my gym clothes.",
"I scraped my knee in gym class."]

br = boto3.client("bedrock-runtime", region_name="us-east-1")
def llm(s):
prompt = (f"Request: \"{s}\"\nWhere should this student go for help? Answer with the name only, from:\n"
+ "\n".join(f"- {k}: {v}" for k, v in desks.items()))
t = time.perf_counter()
r = br.converse(modelId="us.anthropic.claude-haiku-4-5-20251001-v1:0",
messages=[{"role": "user", "content": [{"text": prompt}]}],
inferenceConfig={"maxTokens": 20})
return r["output"]["message"]["content"][0]["text"].strip(), (time.perf_counter() - t) * 1000

d_ms, l_ms = [], []
for s in requests:
a, ms1 = choose(s, "Where should this student go for help?", desks)
ans, ms2 = llm(s)
d_ms.append(ms1); l_ms.append(ms2)
print(f"{s:<52} Decider: {a['choice']} ({ms1:.0f} ms) LLM: {ans} ({ms2:.0f} ms)")
print(f"\nmedian Decider {statistics.median(d_ms):.0f} ms (on this Mac) / LLM {statistics.median(l_ms):.0f} ms (over the network)")
1
2
3
4
5
6
7
I've had a headache and a fever since this morning. Decider: nurse's office (480 ms) LLM: nurse's office (1038 ms)
I left my math worksheet at home. Decider: teachers' room (540 ms) LLM: teachers' room (734 ms)
I want to research dinosaurs for my summer project. Decider: library (493 ms) LLM: library (707 ms)
I forgot my gym clothes. Decider: teachers' room (457 ms) LLM: teachers' room (766 ms)
I scraped my knee in gym class. Decider: nurse's office (482 ms) LLM: nurse's office (735 ms)

median Decider 482 ms (on this Mac) / LLM 735 ms (over the network)
Both got all five right. On my M1 the Decider took about half a second per question and Claude Haiku 4.5 over the network about 0.7 seconds, a smaller gap than I expected. The project reports medians of 115 ms on an NVIDIA RTX 3090 and 153 ms on an M3 Pro Mac for short questions, so hardware matters a lot. The Decider also keeps the conversation on your machine, costs nothing per call, and always returns one of your option names exactly. In my Japanese run, Claude once wrapped its answer in markdown bold (**...**).

If something goes wrong

If you see "The provided model identifier is invalid", your AWS settings probably default to a region outside the US (mine is Tokyo), where the model ID starting with us. doesn't work. Setting region_name="us-east-1" in BedrockModel, as the scripts do, fixes it.
If you get "Connection refused" or the server stops, check that nothing else is using port 8099. A second session I had open started its own Decider server on the same port, and the two collided. You can run the server on another port and set STRANDS_DECIDER_HOST to 127.0.0.1 plus that port (for example 127.0.0.1:8199) before running the scripts; easy.py reads it.

References

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