Lesson 17: Writing BonicBot’s Performance Script
Learning Objective
Use BonicBot’s LLM as a choreographer to turn high-level prompts into validated performance scripts. Use Pydantic to validate speech and physical actions before execution, ensuring every action maps directly to the bonicbot_bridge SDK.
Introduction
So far, the LLM has always replied with plain text. In this lesson, we ask it to fill in a strict form instead — a Pydantic model — listing exact steps of four kinds:
- speak — say a line out loud
- neck — turn the head (
look_left,look_center,look_right) - arm — move one arm into a named pose (
rest,wave,reach_forward,shrug,droop,celebrate,thinking,hug_self) - move — drive the body (
move_forward,move_backward,turn_left,turn_right)
Why Pydantic instead of plain JSON? Plain JSON only checks that the text is valid JSON — not that it makes sense for a robot to act on. Pydantic checks the actual content: every field is present, every type is correct, and every named action is limited to a fixed list BonicBot actually knows how to perform. A step is rejected outright if it’s missing what its action needs — an arm step without an arm_side and arm_pose, for instance, never reaches the robot.
The action set was chosen deliberately narrow, matching only what the real SDK supports and what’s safe to hand to a small local model:
- neck yaw only — the SDK exposes no pitch on
look_*, so BonicBot can’t look up or down - no grippers — kept out of choreography entirely
- no strafing — the SDK only turns in place, so “left/right” movement means
turn_left/turn_right, not sidestepping
Each arm preset is also pre-checked against the SDK’s documented joint limits (shoulder pitch −45°–180°, elbow 0°–50°), so whatever the LLM picks is guaranteed physically safe to send straight into move_left_arm()/move_right_arm().
Two more things make the plan more reliable before it ever reaches the robot:
- Few-shot examples. A handful of worked instruction → output pairs (
"act proud","act sad","greet someone shyly") are sent as prior conversation turns. A small model tends to copy a demonstrated pattern far more reliably than it follows a prose description of what each preset means — without them, a tiny model will often default to the same generic gesture (likewave) regardless of mood. - Self-correction on failure. If the model’s plan fails validation, the exact Pydantic error is sent back to the model as a follow-up turn, and it gets another attempt (up to
MAX_VALIDATION_ATTEMPTS) to fix just what was wrong — instead of the whole instruction being thrown away after one try.
The Activity: give BonicBot an instruction, print its full validated script, then have it perform each step — speaking out loud and printing the exact Bridge SDK call each other step represents, in order.
Setup: Installing Packages
Before running the code, make sure your computer has the required Python packages and system libraries installed. Open a terminal and run:
pip install bonicbot-bridge opencv-python numpy ollama pydantic piper-tts pyaudioWhat each package / tool does
| Package / Tool | Purpose |
|---|---|
pydantic | Data validation library. Defines ChoreographyStep and Choreography schemas with validators (@model_validator) to validate structured LLM action plans. |
ollama | Python client for local LLMs. Uses qwen3.5:0.8b (or qwen3:0.6b) with JSON Schema formatting (format=Choreography.model_json_schema()). |
piper-tts | Fast, local neural Text-to-Speech (TTS) engine. Synthesizes voice audio for speak choreography steps. |
pyaudio | Audio I/O library. Streams synthesized speech directly to system or Bluetooth speakers. |
bonicbot-bridge | The official BonicBot SDK. Maps choreography actions (neck, arm, move) to robot SDK calls (bot.look_left(), bot.move_left_arm(), bot.turn_left()). |
If pip install fails, try pip3 install ... instead, or use a virtual environment:
python3 -m venv bonicbot-env
source bonicbot-env/bin/activate # On Windows: bonicbot-env\Scripts\activate
pip install bonicbot-bridge opencv-python numpy ollama pydantic piper-tts pyaudioHow to run the program
- Install Ollama (one-time setup):
- Windows / Mac: Download and run the installer from ollama.com/download .
- Linux: Run
curl -fsSL https://ollama.com/install.sh | shin your terminal.
- Install PortAudio (system dependency for
pyaudio):- Linux:
sudo apt install portaudio19-dev - macOS:
brew install portaudio
- Linux:
- Pull the LLM model:
ollama pull qwen3.5:0.8b - Save the code into a file, e.g.,
lesson17_choreographer.py. - Run the script:
python lesson17_choreographer.py - Type any prompt into the console (e.g., “act shy and say hello”, “celebrate and say congratulations at the same time”).
- Watch Ollama generate a multi-step structured choreography script, validate it against Pydantic rules, retry automatically if validation fails, and print the SDK action calls step-by-step!
- Type “quit” or press Ctrl+C to exit.
Code
Click to view the complete program
import json
import shutil
import subprocess
import sys
import time
from typing import List, Literal, Optional
import ollama
from pydantic import BaseModel, Field, ValidationError, model_validator
OLLAMA_MODEL = "qwen3.5:0.8b"
SERVER_START_TIMEOUT = 15.0
SERVER_POLL_INTERVAL = 0.5
MAX_VALIDATION_ATTEMPTS = 3
CHAT_OPTIONS = {"temperature": 0.2, "top_p": 0.8, "num_predict": 500}
CHAT_KEEP_ALIVE = "30m"
NeckPose = Literal["look_left", "look_center", "look_right"]
ArmPose = Literal[
"rest", "wave", "reach_forward", "shrug",
"droop", "celebrate", "thinking", "hug_self",
]
MoveAction = Literal["move_forward", "move_backward", "turn_left", "turn_right"]
SYSTEM_PROMPT = (
"You are a choreographer for a small robot named BonicBot. Given a short "
"instruction, write 2 to 5 steps mixing these action types:\n"
"- speak: say one short, friendly sentence\n"
"- neck: turn the head using neck_pose = look_left, look_center, or look_right\n"
"- arm: move one arm using arm_side = left or right, and arm_pose, one of:\n"
" rest (neutral), wave (greeting), reach_forward (offering/pointing),\n"
" shrug (unsure), droop (sad/tired), celebrate (excited/proud),\n"
" thinking (curious/pondering), hug_self (shy/nervous)\n"
"- move: drive the body using move_action = move_forward, move_backward, "
"turn_left, or turn_right. For move_forward or move_backward, set "
"move_distance_meters (0.1 to 3.0) for how far to travel. For turn_left "
"or turn_right, set turn_degrees (1 to 360) for how far to rotate — e.g. "
"a quarter turn is 90, a full turn is 360. The system converts this to "
"real motor timing automatically; never invent a duration yourself for "
"a move step.\n"
"Only ever use these exact literal values, and keep duration_seconds "
"(for speak/neck/arm steps) between 0.5 and 5.0. BonicBot cannot look "
"up or down, cannot use grippers, and cannot strafe sideways — only "
"turn in place.\n"
"IMPORTANT: 'reach_forward' is an ARM pose (one arm reaches out), not the "
"same thing as the robot actually driving forward. If the instruction is "
"literally about the robot moving/walking/driving/going somewhere (e.g. "
"'move forward', 'go forward', 'drive back', 'turn around'), you MUST "
"include a 'move' step with the matching move_action — don't substitute "
"an arm gesture for it, and don't skip it.\n"
"Each step also has an optional run_with_next flag (default false). Set "
"it to true on the FIRST of two steps that should happen at the exact "
"same time as the step right after it — for example if an arm gesture "
"comes before a speak step and they should happen together, set "
"run_with_next=true on that arm step, not on the speak step that "
"follows it. The flag always goes on the earlier step, never the later "
"one. Leave it false for steps that should happen one after another, "
"which is most of the time. Never set run_with_next to true on the "
"last step in a plan, and never pair more than two steps together at "
"once.\n"
"Simultaneity isn't only signaled by the literal phrase 'at the same "
"time' — continuous/ongoing phrasing also means two steps should "
"overlap. If the instruction says something like 'keep moving forward "
"while you talk', 'say hi and keep moving', 'wave as you turn', or "
"'while driving forward, say...', set run_with_next=true on the "
"earlier step so it overlaps with the one after it, the same as you "
"would for 'at the same time'. Only leave run_with_next false when the "
"instruction genuinely describes one thing happening, then another — "
"e.g. 'turn around and walk away' is sequential (finish turning, THEN "
"walk), not simultaneous.\n"
"Base every plan strictly on the new instruction you're given — never "
"reuse a previous example's wording or steps unless the new instruction "
"genuinely calls for the same reaction."
)
class ChoreographyStep(BaseModel):
action: Literal["speak", "neck", "arm", "move"]
speech_text: Optional[str] = Field(default=None, max_length=200)
neck_pose: Optional[NeckPose] = None
arm_side: Optional[Literal["left", "right"]] = None
arm_pose: Optional[ArmPose] = None
move_action: Optional[MoveAction] = None
turn_degrees: Optional[float] = Field(default=None, ge=1, le=360)
move_distance_meters: Optional[float] = Field(default=None, ge=0.1, le=3.0)
duration_seconds: float = Field(default=1.5, ge=0.5, le=5.0)
run_with_next: bool = Field(default=False)
@model_validator(mode="after")
def fields_match_action(self):
if self.action == "speak" and not self.speech_text:
raise ValueError("a 'speak' step needs speech_text")
if self.action == "neck" and not self.neck_pose:
raise ValueError("a 'neck' step needs neck_pose")
if self.action == "arm" and not (self.arm_side and self.arm_pose):
raise ValueError("an 'arm' step needs arm_side and arm_pose")
if self.action == "move":
if not self.move_action:
raise ValueError("a 'move' step needs move_action")
if self.move_action in ("turn_left", "turn_right") and self.turn_degrees is None:
raise ValueError("a turning move step needs turn_degrees")
if self.move_action in ("move_forward", "move_backward") and self.move_distance_meters is None:
raise ValueError("a forward/backward move step needs move_distance_meters")
return self
class Choreography(BaseModel):
title: str
steps: List[ChoreographyStep] = Field(min_length=1, max_length=6)
@model_validator(mode="after")
def run_with_next_pairs_are_valid(self):
last_index = len(self.steps) - 1
for i, step in enumerate(self.steps):
if step.run_with_next:
if i == last_index:
raise ValueError(
"the last step can't set run_with_next=true — there's no next step to "
"pair with. If this step should run together with the one before it, "
"set run_with_next=true on that EARLIER step instead, and set this "
"last step's run_with_next back to false."
)
if self.steps[i + 1].run_with_next:
raise ValueError(
"run_with_next can only pair two adjacent steps at a time. Keep "
"run_with_next=true on just one of these two steps and set the "
"other one back to false."
)
return self
FEW_SHOT_EXAMPLES = [
(
"act proud",
{
"title": "Proud",
"steps": [
{"action": "speak", "speech_text": "I did it! I'm so proud of that.", "duration_seconds": 1.5},
{"action": "neck", "neck_pose": "look_center", "duration_seconds": 1.0},
{"action": "arm", "arm_side": "right", "arm_pose": "celebrate", "duration_seconds": 1.5},
{"action": "move", "move_action": "move_forward", "move_distance_meters": 0.3},
],
},
),
(
"act sad",
{
"title": "Sad",
"steps": [
{"action": "neck", "neck_pose": "look_left", "duration_seconds": 1.0},
{"action": "arm", "arm_side": "left", "arm_pose": "droop", "duration_seconds": 2.0},
{"action": "speak", "speech_text": "I'm feeling a little sad today.", "duration_seconds": 1.5},
],
},
),
(
"greet someone shyly",
{
"title": "Shy Greeting",
"steps": [
{"action": "neck", "neck_pose": "look_right", "duration_seconds": 1.0},
{"action": "arm", "arm_side": "left", "arm_pose": "hug_self", "duration_seconds": 1.5},
{"action": "speak", "speech_text": "Oh... um, hi there.", "duration_seconds": 1.5},
],
},
),
(
"move forward",
{
"title": "Move Forward",
"steps": [
{"action": "speak", "speech_text": "Moving forward now.", "duration_seconds": 1.0},
{"action": "move", "move_action": "move_forward", "move_distance_meters": 0.5},
],
},
),
(
"back away nervously",
{
"title": "Back Away",
"steps": [
{"action": "neck", "neck_pose": "look_left", "duration_seconds": 1.0},
{"action": "move", "move_action": "move_backward", "move_distance_meters": 0.4},
{"action": "speak", "speech_text": "Sorry, I need a little space.", "duration_seconds": 1.5},
],
},
),
(
"turn around and walk away",
{
"title": "Turn And Go",
"steps": [
{"action": "speak", "speech_text": "Okay, I'll head off now.", "duration_seconds": 1.5},
{"action": "move", "move_action": "turn_right", "turn_degrees": 180},
{"action": "move", "move_action": "move_forward", "move_distance_meters": 0.5},
],
},
),
(
"wave hello and say hi at the same time",
{
"title": "Wave And Greet",
"steps": [
{"action": "speak", "speech_text": "Hi there, so good to see you!", "run_with_next": True, "duration_seconds": 1.5},
{"action": "arm", "arm_side": "right", "arm_pose": "wave", "duration_seconds": 1.5},
{"action": "neck", "neck_pose": "look_center", "duration_seconds": 1.0},
],
},
),
(
"celebrate and say congratulations at the same time",
{
"title": "Celebrate And Congratulate",
"steps": [
{"action": "arm", "arm_side": "right", "arm_pose": "celebrate", "run_with_next": True, "duration_seconds": 1.5},
{"action": "speak", "speech_text": "Wow, congratulations! That's amazing news!"},
{"action": "neck", "neck_pose": "look_center", "duration_seconds": 1.0},
],
},
),
]
for _instruction, _output in FEW_SHOT_EXAMPLES:
Choreography.model_validate(_output)
_FEW_SHOT_INSTRUCTIONS = {instr for instr, _ in FEW_SHOT_EXAMPLES}
_FEW_SHOT_OUTPUTS = [out for _, out in FEW_SHOT_EXAMPLES]
def ensure_ollama_installed():
if shutil.which("ollama") is None:
print("⚠️ Install Ollama once from https://ollama.com/download, then re-run.")
sys.exit(1)
def ensure_ollama_running():
try:
ollama.list()
return
except Exception:
pass
print("Starting Ollama server...")
subprocess.Popen(["ollama", "serve"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
start = time.time()
while time.time() - start < SERVER_START_TIMEOUT:
try:
ollama.list()
print("✅ Ollama server is up.")
return
except Exception:
time.sleep(SERVER_POLL_INTERVAL)
def ensure_model_pulled(model_name):
try:
ollama.show(model_name)
return
except Exception:
pass
print(f"Downloading model '{model_name}'...")
for _ in ollama.pull(model_name, stream=True):
pass
print(f"✅ Model '{model_name}' ready.")
def warm_up_model(model_name):
print("Warming up model...")
ollama.chat(
model=model_name,
messages=[{"role": "user", "content": "hello"}],
options=CHAT_OPTIONS,
keep_alive=CHAT_KEEP_ALIVE,
think=False,
)
print("✅ Model warm.")
def ask_choreographer(instruction, max_attempts=MAX_VALIDATION_ATTEMPTS):
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
for example_instruction, example_output in FEW_SHOT_EXAMPLES:
messages.append({"role": "user", "content": example_instruction})
messages.append({"role": "assistant", "content": json.dumps(example_output)})
messages.append({"role": "user", "content": instruction})
for attempt in range(1, max_attempts + 1):
print(f" 🧠 thinking (attempt {attempt}/{max_attempts}): ", end="", flush=True)
t0 = time.time()
raw_content = ""
for chunk in ollama.chat(
model=OLLAMA_MODEL,
format=Choreography.model_json_schema(),
messages=messages,
options=CHAT_OPTIONS,
keep_alive=CHAT_KEEP_ALIVE,
think=False,
stream=True,
):
piece = chunk["message"]["content"]
raw_content += piece
print(piece, end="", flush=True)
print(f"\n ⏱ inference took {time.time() - t0:.2f}s")
try:
choreography = Choreography.model_validate_json(raw_content)
except ValidationError as e:
print(f"⚠️ Attempt {attempt}/{max_attempts} didn't pass validation:")
print(e)
feedback = str(e)
else:
if choreography.model_dump() in _FEW_SHOT_OUTPUTS and instruction not in _FEW_SHOT_INSTRUCTIONS:
print(f"⚠️ Attempt {attempt}/{max_attempts} returned a few-shot example verbatim, not a new plan.")
feedback = (
"That response was one of the earlier example plans copied verbatim, "
"not a new plan for THIS instruction. Write a plan specific to: "
f"'{instruction}'."
)
else:
return choreography
if attempt == max_attempts:
print("⚠️ Out of attempts — nothing will be performed.")
return None
print("↻ Sending the issue back to the model to retry...\n")
messages.append({"role": "assistant", "content": raw_content})
messages.append({
"role": "user",
"content": (
f"That plan didn't work:\n{feedback}\n"
"Fix ONLY what's wrong and send a complete, corrected "
"Choreography that satisfies the schema."
),
})
return None
def main():
ensure_ollama_installed()
ensure_ollama_running()
ensure_model_pulled(OLLAMA_MODEL)
warm_up_model(OLLAMA_MODEL)
print("✅ Ready. Type an instruction, or 'quit' to stop.")
print(' Example: "act shy and say hello"\n')
while True:
instruction = input("Instruction> ").strip()
if not instruction:
continue
if instruction.lower() in {"quit", "exit"}:
break
choreography = ask_choreographer(instruction)
if choreography is None:
continue
print(f"\n📋 {choreography.title}")
print(choreography.model_dump_json(indent=2))
print()
if __name__ == "__main__":
main()
Install the requirements before running:
pip install ollama pydantic piper-tts pyaudio --break-system-packages🔊 No BonicBot Bridge connection is needed here — pair your Bluetooth speaker directly to the computer running this script (or just use its built-in speakers), and BonicBot’s spoken lines will play through it normally.
⚠️ neck_step(), arm_step(), and move_step() only print the Bridge SDK call each step represents in this lesson. Swap each print for the real bot.* call (e.g. bot.look_left(), bot.move_left_arm(shoulder, elbow), bot.move_forward(speed, duration)) once you’re ready to move the physical robot.
Code Walkthrough
Line-by-line explanation
- Configuration & Action Types (
lines 58–76) — Defines system model (qwen3.5:0.8b), validation attempt limits (MAX_VALIDATION_ATTEMPTS = 3), and strictLiteraltypes for actions:NeckPose,ArmPose(rest,wave,reach_forward,shrug,droop,celebrate,thinking,hug_self), andMoveAction. - Pydantic Schemas & Custom Validation (
lines 129–184) —ChoreographyStepvalidates that step attributes match the declared action type (e.g.speakrequiresspeech_text;armrequiresarm_sideandarm_pose).Choreographyenforces sequence rules (e.g.run_with_nextcannot be true on the last step). - Few-Shot Examples (
lines 186–282) — Demonstrates complete worked examples ("act proud","act sad","greet someone shyly") as prior conversation turns to guide small LLMs away from generic defaults likewave. - Ollama Setup & Model Warmup (
lines 284–330) —ensure_ollama_running()starts the background server,ensure_model_pulled()fetchesqwen3.5:0.8b, andwarm_up_model()warms up model weights to minimize initial response latency. - LLM Choreographer & Automatic Retry Loop (
lines 332–390) —ask_choreographer()passes system prompts and few-shot examples to Ollama formatted withChoreography.model_json_schema(). If validation fails, it feeds the exact PydanticValidationErrorback to Ollama to retry (up to 3 times). - Interactive Script Execution (
lines 392–418) — Prompts for user input, triggers LLM choreography generation, validates the resulting plan, and outputs the structured JSON script.
Expected Output
Click to see expected output
Visual Output:
Terminal Output:
✅ Ready. Type an instruction, or 'quit' to stop.
Example: "act shy and say hello"
Instruction> act shy and say hello
📋 Shy Hello
{
"title": "Shy Hello",
"steps": [
{ "action": "neck", "speech_text": null, "neck_pose": "look_right", "arm_side": null, "arm_pose": null, "move_action": null, "duration_seconds": 1.0 },
{ "action": "arm", "speech_text": null, "neck_pose": null, "arm_side": "left", "arm_pose": "hug_self", "move_action": null, "duration_seconds": 1.5 },
{ "action": "speak", "speech_text": "Oh... um, hello there.", "neck_pose": null, "arm_side": null, "arm_pose": null, "move_action": null, "duration_seconds": 1.5 }
]
}
🎬 Performing: Shy Hello
🤖 bot.look_right()
🤖 bot.move_left_arm(shoulder=30.0, elbow=50.0) # hug_self
🗣️ BonicBot says: "Oh... um, hello there."
✅ Performance complete.The script is printed before it’s performed. Every field is present in the JSON — null just means that field doesn’t apply to that step’s action.
🔧 Under the Hood
What if the LLM gets it wrong?
format=Choreography.model_json_schema() tells Ollama to shape its answer like our schema — but that’s not a guarantee, so we validate again:
try:
return Choreography.model_validate_json(raw_content)
except ValidationError as e:
...Unlike a plain reject-and-discard, this lesson gives the model a chance to fix itself: the exact validation error is sent back as a follow-up message, and the model gets up to MAX_VALIDATION_ATTEMPTS tries to send a corrected plan. Only once every attempt is exhausted does ask_choreographer() return None — and in that case, nothing is spoken and no action is printed.
Because every action’s fields are locked to a fixed list (neck_pose, arm_pose, move_action are all Literal types) and a model_validator checks that a step actually has the fields its action needs, the LLM can neither invent an action BonicBot doesn’t support nor submit a step that’s missing required information — the schema itself blocks it before it ever reaches the retry loop.
Why give the model worked examples instead of just describing the presets?
Early testing showed that even with clear descriptions in the system prompt (celebrate — “excited/proud”), a small local model like qwen2.5:0.5b kept defaulting to the same generic gesture (wave) regardless of the instruction’s mood. Describing what a preset means in prose turned out to be weak steering for a model this size.
FEW_SHOT_EXAMPLES fixes this by showing, not telling: a few complete instruction → output pairs are sent as prior conversation turns before the real instruction, so the model has direct precedent for reaching past the obvious default. Small models tend to pattern-match a demonstrated example far more reliably than they follow an instruction about it.
Student Challenge
Add a speed_level field ("calm", "normal", "energetic") to move steps, and have move_step() scale DEFAULT_LINEAR_SPEED/DEFAULT_TURN_SPEED up or down based on it — so an excited choreography drives faster than a sad one.
Reflection Question
This schema blocks BonicBot from performing an action it doesn’t support, and blocks a step that’s missing a required field. What’s a mistake it still wouldn’t catch?