Put the GrowBot brain on your own robot. The brain runs in the GrowBot app and picks the moves. Your robot's chip plays them. Today every move is two values, left and right, and your chip decides what they do.
For a rover or tank with one motor driving each side, wheels mode makes the brain drive instead of walk.
90 means stop. With just the wheels link, gestures can still send 0 to 180, which is full speed. Declare a range, usually [50,130], in your body settings, and cap speeds on your chip too.
Today the brain only sends two values, left and right. Your chip decides what they mean, so extra motors can only follow those two values or play moves your chip software defines. Two ways to do that:
A small servo arm on a stock robot works another way, for now by hand: see Add an arm.
Drop a note in the GrowBot Discord with what you're making and we'll help!
A stock Pico robot can carry a small arm of up to four 180° hobby servos next to its wheels or legs. You move it by hand on the arm page. The brain cannot move the arm yet: it still sends only left and right. Brain control comes later, with the plan for bodies with more servos.
mpremote cp arm_body.py arm_config.py :. Copy within a few seconds of plugging in USB: once the robot is online, its watchdog restarts it 8 seconds after you stop it. A cut-off copy is fine, just unplug, plug back in and copy again.arm: line with the sockets it drives, or why the arm stays off.How it behaves: every move is clamped to the joint's min and max, and a joint that is holding moves no faster than 60° a second. The arm holds its pose after a move. It lets go on STOP, when the robot loses its connection, after 2 minutes with no new move, and 15 s after the arm page closes, sleeps or loses its signal. The grip lets go 15 s after your last move, so a grip squeezing something does not cook its servo. The speed and these times are set in arm_config.py. A servo cannot report where it is, so after the arm lets go a joint can sag, and the next move first snaps it back to where it was at the servo's own speed, one joint at a time. Keep hands clear and start with small moves. Switching on works the same way: each joint goes to rest, one at a time, then lets go, so leave the arm at rest before you switch off. When the robot restarts itself (a lost Wi-Fi, the watchdog) the arm stays limp until the next move. Nothing watches for a fall while you use the arm page, so keep a finger near STOP. Nobody has run this on a real arm yet: tell us how yours goes in Discord.
GrowBot is a brain meant to work in any body. Today it runs in your phone's browser: it thinks with a cloud AI model, uses the phone's sensors, and sends moves through our relay to your robot's chip.
So the phone does two jobs: it runs the brain, and its sensors are part of the robot. Walks and drives use the phone's motion sensor, so the phone rides on the robot. On a PC the app can gesture but refuses walks and drives, and a phone lying nearby reads its own tilt, not the robot's.
Later the brain could run on a nearby computer or inside the robot, with no phone.
Works today
Coming soon
The app and your body meet at a relay, on the same pairing code. The relay forwards any JSON object and drops anything else; your chip maps the messages to hardware. For your own brain driving a stock robot, see agent-docs.
GrowBot app (phone) ──▶ relay wss://growbot-relay.growbot.workers.dev/d/<code> ──▶ your machine
A body opens wss://growbot-relay.growbot.workers.dev/d/<code> and sends {"t":"hello","id":"<code>"} first. The app attaches to the same code with {"t":"attach"}. Make up your own: gb- followed by lowercase letters and digits, like gb-k7q2m9x4. The app lowercases what you type, and the relay treats upper and lower case as different codes.
The app reads a body block that says what your robot is. The default is /v3/body.json. Each GrowBot saves its own copy the first time it wakes, and that copy wins from then on. To change it, save your soul file (brain, Settings, soul, save the file), edit its body, and load it back with "bring one in". These fields change what the app does:
| what you declare | what changes |
|---|---|
walk.present: falseor "wheel" in idor in geometry.drive | Wheels mode. The brain is told it has wheels, not legs, and stops thinking it tipped over when the phone sits at an angle. Travel drives forward or pivots left or right; there is no reverse. Drives use your saved calibration signs over the body’s defaults, but not trims. Stock leg direction defaults do not change wheels. On wheels a held value keeps driving. Drives end at 90,90 (stop), but a gesture ends on its last frame (the brain is asked to end at 90,90), so stop your motors when a plan finishes. Fall recovery and face tracking turn off. |
servos[0].band | The range for both motors (extra servos are ignored). It limits gestures, held poses and get-ups, and a get-up will not start if its stand falls outside it. On legs, walks and spins ignore it and can use the full 0 to 180, so limit your servos on the chip too. On wheels it also scales drive speed, never more than 25 from 90. Always declare it. Today a servos list without a band stops the brain from thinking. The stock legs band is [0,180]. A wheels body with no servos list at all falls back to [50,130]. A saved band wins, even with ?wheels=1. |
gestures | Named moves. On legs, the brain sees these names as its saved poses and can replay one by name. In wheels mode it is not shown your names. Names must be lowercase letters, numbers, single spaces, - or _, up to 16 characters, or they can never be played. Agents using agent-docs get the stock list from /v3/body.json (wiggle, bow, sway), not yours. |
limits.duty_mslimits.duty_window_ms | Limits how much walking, spinning, driving and holding fits in a time window, for example duty_ms 10000 with duty_window_ms 60000. The window defaults to 60 s. Gestures don't count. Leave both out for no limit. Don't use exactly 20000 and 60000, because the app removes that pair as an old default. |
walk.cal, walk.policy | walk.cal is the default calibration. The phone's saved trim and other settings override it. For legs, V3 uses the body’s direction signs by default; ?stockdir=0 restores saved direction signs. Wheels keep saved direction signs over the body’s defaults. The app ignores walk.policy. Its walks and spins are built in and assume the stock two-leg body, so a different leg arrangement needs its own gait. |
Mode: ?wheels=1 turns on wheels mode for that link. To leave wheels mode, open the app without ?wheels=1. If your saved body declares wheels, change it there; ?wheels=0 won't override it. A ?wheels= link without ?id= won't reconnect to your last robot, so pair again.
Spins on legs: custom leg bodies use the older built-in spin. The stock two-leg body uses a newer trained spin when it can; ?normalspin=0 turns it off. Wheels pivot with their drive.
| direction | message | what you do |
|---|---|---|
| you → | {"t":"hello","id":"<code>"} | First frame, always. You are now the body on that code. |
| you → | {"t":"hello","id":"<code>","caps":["hb"],"boot":{"reset":1,"up_ms":22050,"fw":"x"}} | Extras on the same first frame. caps:["hb"] promises a heartbeat (see the next row). boot says why and when you started. The relay keeps it in its log at /d/<code>/log and passes it with the next awake:1, but the phone app does not use it yet. |
| you → | {"t":"hb"} | Every 15 s, exactly this text, once your hello has caps:["hb"]. Without it, a body that loses power can show as online for many minutes. The relay answers {"t":"hb_ack"}. If a heartbeat body goes quiet for 45 s, the relay closes it with code 4002 at its next check (every 30 s, so 45 to 75 s after your last message) and tells the app you are gone. WebSocket pings do not count. |
| ← you | {"t":"pose","lr":"70,110"} | Walk lane. Two numbers, 0 to 180, newest wins, no reply. A walk sends about 25 per second; wheels mode sends about 10. Apply it and move on. |
| ← you | {"t":"act","rid":7,"steps":[{"l":60,"r":120,"ms":400}],"mode":"replace"} | Gesture lane. Glide to each pair over its ms. Ack straight away, then play. replace cuts in mid-move, append waits behind. |
| ← you | {"t":"routine","rid":8,"name":"wiggle"} | A named move your chip plays. The brain never sends this; only the tester's wiggle button does. |
| ← you | {"t":"stop","rid":9} | Drop everything and go safe, now. Ack it. |
| ← you | {"t":"arm","a":"90,60,120,90"} | Arm lane, experimental. Only the arm page sends it, never the app. A body without an arm ignores it. arm_body.py lists its forms (angles, named poses, let go, a question about the arm, and the page's keepalive) and its acks. See Add an arm. |
| you → | {"t":"ack","rid":7,"ok":1,"queued_ms":400} | Your reply to act, routine and stop. Echo the rid, and send it before you play the motion. Send ok:0 to refuse; the stock chip software refuses plans over 15 s in total. The app only logs a refusal. |
| relay → | {"t":"status","awake":0|1} | The relay telling the app whether a body is connected. You never send this. It is what makes the app say the robot is online. |
| relay → | {"t":"alive","quiet_ms":5000} | To the app, every 30 s while a heartbeat body is connected: it is still there, even when holding still. |
What the live app sends: attach, then only pose, act (always "mode":"replace") and stop. It ignores anything else your body sends, like battery or sensor readings.
Ways to use the left and right values on a robot that isn't two legs. It all happens in your chip software.
l and r become wheel speeds, and 90 is stop. With the stock calibration (L_SIGN -1, like the legs), wheels mode sends forward as l below 90 and r above 90, so flip l first. Check your effective signs: saved wheel signs take precedence, and a body and phone declaring no signs use +1/+1 instead. Stopping on silence matters more here: on legs a stale pose is a held limb, on wheels it is a machine that keeps going.
l = 180 - l # undo the stock left mirror: forward is now both above 90 speed_left = (l - 90) / 40.0 # -1.0 .. +1.0 across a 50..130 band speed_right = (r - 90) / 40.0 if abs(speed_left) < 0.06: speed_left = 0 # deadband, motors buzz near zero if abs(speed_right) < 0.06: speed_right = 0
Declare a band of [50,130] in your body block, clamp on the chip too, and make the auto-stop brake rather than coast. The stock chip software goes limp after 500ms of silence. Wheels mode sends a drive frame every 100ms, so use about 300ms.
Use wheels mode for rolling. It sends steady speeds, then 90,90 to stop. Don't map the leg walk onto wheels: it steps back and forth, so a rover twitches. Need throttle and yaw instead? Use l after the flip:
throttle = (l + r) / 2 - 90 yaw = (l - r) / 2
Real example, a robot lawnmower. Someone put GrowBot on a Husqvarna Automower 310. The Pico chip software and write-up are at ports/automower. Its README was written before wheels mode existed: ignore its note that wheels mode is not live, leave GAIT_ROLL off, and use wheels mode. The numbers below are from that build:
machine.Timer(0) raises on rp2 (use -1), so the driver never starts and the board never boots. Pins 32 and 33 do not exist. GP23, 24, 25 and 29 belong to the Wi-Fi chip, so an ESP32 pin like 25 kills the radio.l = 180 - l # same flip as above throttle = ((l - 90) + (r - 90)) / 2.0 / 40.0 # forward and back steer = ((l - 90) - (r - 90)) / 2.0 / 40.0 # -1 left .. +1 right
After the flip, forward is both values above 90, and a wheels-mode turn is a pivot: one side forward, one back. A car can't pivot, so this formula gives zero throttle with the wheels turned. Give a turn a minimum throttle so it becomes the car's tightest circle, or leave the car parked. The brain is not told either way.
l and r drive shoulder and elbow, or pan and tilt, and the other joints hold a posture you choose.act plan is a sequence with timings. Ignore the numbers and treat each step as a cue for a move of your own.Map the value to whatever your driver wants inside one function, like apply_pair in the example below: PWM duty plus a direction pin, a velocity setpoint, a step target, an RC pulse. The rest of the code does not change.
Something in the room has to reach the internet, because the app and the body meet at the relay, which is in the cloud. It does not have to be the machine:
Fully offline does not work today. The app is an https page, so it cannot reach a plain http board on your local network. That is why bodies dial out to the relay.
Complete and working. It prints instead of moving anything. It keeps reading the socket while a move plays, so stop lands right away and the auto-stop keeps its own clock. Replace the two hardware functions and it drives a robot.
# growbot_body.py · pip install websockets · python growbot_body.py
import asyncio, json, websockets
RELAY = "wss://growbot-relay.growbot.workers.dev"
CODE = "gb-k7q2m9x4" # make up your own: gb- then lowercase letters and digits
DEADMAN = 0.5 # no pose for 500ms -> go safe. do not skip this one.
ROUTINES = {"wiggle": [{"l": 60, "r": 120, "ms": 400}, {"l": 120, "r": 60, "ms": 400},
{"l": 90, "r": 90, "ms": 300}]}
def apply_pair(l, r): # <-- YOUR MACHINE. two numbers, 0..180, 90 = neutral / stop
print("pair %6.1f %6.1f" % (l, r), flush=True)
def go_safe(): # <-- YOUR MACHINE. limp, brake, whatever safe is on your machine
print("safe", flush=True)
# this toy jumps to each pair and holds it for ms. the stock chip software glides there
# over ms instead. for real motors, use act_engine.py.
async def play(steps): # runs as its OWN task so the socket stays live
for s in steps:
apply_pair(s.get("l", 90), s.get("r", 90))
await asyncio.sleep(s.get("ms", 0) / 1000)
go_safe() # natural end. a cancel means someone else took over
async def main():
move = None
def busy(): return move is not None and not move.done()
def take_over():
if busy(): move.cancel()
async with websockets.connect("%s/d/%s" % (RELAY, CODE)) as ws:
await ws.send(json.dumps({"t": "hello", "id": CODE})) # this line makes you a body
print("body online as", CODE, flush=True)
streaming = False
try:
while True:
try:
m = json.loads(await asyncio.wait_for(ws.recv(), timeout=DEADMAN))
except asyncio.TimeoutError:
if streaming and not busy(): # auto-stop: silence = safe
go_safe(); streaming = False
continue
t = m.get("t")
if t == "pose": # a pose preempts a plan
take_over()
l, r = (float(x) for x in m.get("lr", "90,90").split(","))
apply_pair(l, r); streaming = True
elif t in ("act", "routine"):
steps = m.get("steps") if t == "act" else ROUTINES.get(m.get("name", ""), [])
await ws.send(json.dumps({"t": "ack", "rid": m.get("rid"), "ok": 1,
"queued_ms": sum(s.get("ms", 0) for s in steps or [])}))
take_over(); streaming = False # ack FIRST, then start moving
move = asyncio.create_task(play(steps or []))
elif t == "stop":
take_over(); streaming = False; go_safe()
await ws.send(json.dumps({"t": "ack", "rid": m.get("rid"), "ok": 1, "queued_ms": 0}))
finally:
take_over(); go_safe() # every exit path ends safe
asyncio.run(main())
finally above runs on errors and a dropped link, but not on kill: SIGTERM ends this script with no cleanup. If you run a body as a service, add a signal handler that raises so cleanup runs, and rely on an auto-stop in the hardware as the real backstop.If you write both ends, you can send any messages you like over the relay, like more motors or sensor readings. Your own brain then takes the place of the phone app. The body is the loop above with t values you invent. Your brain sends attach instead of hello.
# brain side (your laptop). the body side answers with anything it wants.
await ws.send(json.dumps({"t":"attach","id":CODE,"code":CODE}))
await ws.send(json.dumps({"t":"joints","rid":1,
"q":[10,20,30,40,50,60,70,80,90,100,110,120],"ms":250}))
# ...and the body pushes sensors back up the same socket:
# {"t":"telemetry","q":[...],"imu":{"pitch":3.2,"roll":-1.1},"load_a":0.42}
Keep hello and attach exactly as they are: that is how the relay tells the two sides apart.
"caps":["hb"] in your hello and send {"t":"hb"} every 15 s, so the relay notices when your body dies.ws_open, hello_msg, the serve loop) is plain protocol work, and it only touches motors through two functions, board.servoWrite and board.release. It also has what the toy above skips: link and relay heartbeats, re-dial, watchdog, self heal. growbot-body-kit.zip carries the same file.{"t":"arm"} frames, with clamps, a speed limit and an idle let-go. Optional: see Add an arm./eyes off or set camera.enabled to false in body.v0.json to turn it off), talks, gestures, remembers and dreams, and takes a few short steps forward or back with the old walk policy (rough on 64 mm legs, never a spin). A stance check and the motion sense calibration come with it, and its tests run on a laptop with no hardware. Start with its README.agent-harness/, a separate brain loop and body adapter reference.PROTOCOL.md and conformance.html in the body kit zip, and the public repo's protocol/ folder. They describe old local-network chip software the app no longer uses, so a board can pass conformance.html and still never connect. Use /firmware/relay_chip.py and this page./d/<code>/log. Treat the code like a password and keep it out of public posts.hello or attach, the relay replies {"t":"err"} and ignores you until you send one.back to the app · driving a stock robot with your own brain instead: agent-docs · built something? drop a note in Discord