Skip to Content
BonicBot A2DevelopmentArtificial IntelligenceLesson 17: Writing BonicBot's Performance Script

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 (like wave) 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 pyaudio

What each package / tool does

Package / ToolPurpose
pydanticData validation library. Defines ChoreographyStep and Choreography schemas with validators (@model_validator) to validate structured LLM action plans.
ollamaPython client for local LLMs. Uses qwen3.5:0.8b (or qwen3:0.6b) with JSON Schema formatting (format=Choreography.model_json_schema()).
piper-ttsFast, local neural Text-to-Speech (TTS) engine. Synthesizes voice audio for speak choreography steps.
pyaudioAudio I/O library. Streams synthesized speech directly to system or Bluetooth speakers.
bonicbot-bridgeThe 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 pyaudio

How to run the program

  1. 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 | sh in your terminal.
  2. Install PortAudio (system dependency for pyaudio):
    • Linux: sudo apt install portaudio19-dev
    • macOS: brew install portaudio
  3. Pull the LLM model:
    ollama pull qwen3.5:0.8b
  4. Save the code into a file, e.g., lesson17_choreographer.py.
  5. Run the script:
    python lesson17_choreographer.py
  6. Type any prompt into the console (e.g., “act shy and say hello”, “celebrate and say congratulations at the same time”).
  7. 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!
  8. 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 strict Literal types for actions: NeckPose, ArmPose (rest, wave, reach_forward, shrug, droop, celebrate, thinking, hug_self), and MoveAction.
  • Pydantic Schemas & Custom Validation (lines 129–184)ChoreographyStep validates that step attributes match the declared action type (e.g. speak requires speech_text; arm requires arm_side and arm_pose). Choreography enforces sequence rules (e.g. run_with_next cannot 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 like wave.
  • Ollama Setup & Model Warmup (lines 284–330)ensure_ollama_running() starts the background server, ensure_model_pulled() fetches qwen3.5:0.8b, and warm_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 with Choreography.model_json_schema(). If validation fails, it feeds the exact Pydantic ValidationError back 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?

Last updated on