Bring your own body

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.

Start here

Two drive motors: use wheels mode

For a rover or tank with one motor driving each side, wheels mode makes the brain drive instead of walk.

  1. Match the electronics to your motors. Ordinary DC motors need a motor driver, usually a two-motor H-bridge, rated for your battery voltage and each motor's stall current. Servo outputs can't drive DC motors. Continuous-rotation servos use servo connections instead.
  2. Wire with power off. Connect each motor to the driver's motor outputs, the chip's control pins to the driver's inputs, and a suitable motor supply to its power terminals. Connect chip and driver grounds together. Follow your board's pinout. Never power motors from signal pins.
  3. Use chip software made for that wiring. It must turn the two values into motor speeds, and stop the motors when commands stop arriving (the auto-stop). The stock chip software sends servo signals on two ports only. That works for continuous-rotation servos, but at power-on it wiggles both outputs for a few seconds, so keep the wheels off the ground. DC motors on an H-bridge need other chip software, like the automower port.
  4. Open GrowBot in wheels mode, tap brain, then Settings, then Pair on the body row, and enter your pairing code. Bookmark the page after pairing so the link keeps the code.
  5. Test with the wheels raised. In the tester, under Calibrate, tap EXPERT and slide Direct pose Left a little below 90 and Right a little above 90. Both wheels should turn forward, because the stock calibration mirrors the left side, like the legs. If one turns backward, fix it in your chip software. Then, in wheels mode, drive a little, stop, and close the app mid-drive: the wheels must stop every time. Keep the motor power switch within reach.

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.

More than two motors: custom chip software

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.

Building something?

Drop a note in the GrowBot Discord with what you're making and we'll help!

Add an arm experimental

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.

  1. Wiring, power off. Wheels or legs stay on sockets 0 and 1. The arm goes on sockets 2, 3, 4 and 5 (base, shoulder, elbow, grip). Leave 8 and 9 empty: the chip software checks those pins for a servo chip at power-on. Other free sockets are 6, 7 and 10 to 15. This needs the Waveshare Pico Servo Driver from the build guide, where each socket number is its Pico pin (socket 2 is GP2). Boards with a PCA9685 servo chip, and other direct-wire boards like the Kitronik Simply Robotics, are not supported: the arm stays off.
  2. Power. Six servos can draw more current than the kit's AA pack handles well. We recommend two 18650 cells in series (2S, 7.4 V) in a holder with a protection board, on the board's battery input: Waveshare rates it 4.5 to 26 V and gives the servos 5 V. Use short, thick wires. A separate servo supply needs its ground joined to the board's ground. Never power servos from USB: do every USB step below with the battery off and the arm unplugged, and never connect USB and the battery at the same time.
  3. Install the chip software with the USB installer in build step 02, as usual. It never installs the arm, so a robot without the two files below runs exactly as before.
  4. Copy two files onto the Pico, next to main.py: arm_body.py and arm_config.py. In Thonny, open each one and use File, Save as, Raspberry Pi Pico, with the same name. Or run 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.
  5. Set arm_config.py. Give each joint its socket, min and max. Its numbers are a cautious guess, not a measured arm, so start narrow and widen once you know where each joint binds. After a restart the Pico's console (Thonny) prints one arm: line with the sockets it drives, or why the arm stays off.
  6. Open growbot.dev/tester/arm.html, enter the pairing code and move one joint at a time. Close GrowBot first: GrowBot ends its moves with a stop, and a stop lets the arm go limp.

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.

How GrowBot bodies work, and where it's going

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

Technical reference

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.

Experimental The wire format can change.
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.

Tell the app what you are

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 declarewhat changes
walk.present: false
or "wheel" in id
or 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].bandThe 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.
gesturesNamed 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_ms
limits.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.policywalk.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.

The wire, in full

directionmessagewhat 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.

Mapping cookbook

Ways to use the left and right values on a robot that isn't two legs. It all happens in your chip software.

Differential drive: mower, tank tracks, rover, two motor RC car

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:

One steering servo plus a throttle: a normal RC car

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.

Six servos, a hexapod, an arm

DC motors, steppers, ESCs, anything that is not a hobby servo

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.

Does my machine need Wi-Fi?

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.

A whole body in 50 lines

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())
A killed process does not clean up. The 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.

Your own protocol, both ends

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.

Rules a body should honor

  1. Auto-stop. When commands stop arriving, go safe: limp on legs, brake on wheels. This is the difference between a robot that stops when a phone drops off Wi-Fi and one that drives into a wall.
  2. Heartbeat. Put "caps":["hb"] in your hello and send {"t":"hb"} every 15 s, so the relay notices when your body dies.
  3. Ack immediately, then move. Reply to act, routine and stop within a few milliseconds and play the motion afterwards. Never block the socket for the length of a move.
  4. 90 is neutral, and you define safe. Whatever the two numbers mean on your machine, 90 is the resting value. Limp is right for a desk toy, braking is right for wheels, and a loaded joint that goes limp falls.
  5. Clamp on your side too. The app keeps gestures and held poses inside your declared band, but walks, spins and wheel drives can go past it. Refuse anything that would hurt the machine.
  6. Go safe on disconnect. Link dies, process exits, Ctrl-C: release or brake on the way out. A body that holds its last command after the app is gone will burn something.
  7. Power from the drive rail, common ground. Never off a logic pin. Two hobby servos alone can pull about 2A between them.
  8. Interpolate. Moves look alive when the body glides between keyframes at about 50Hz. Snapping works and looks like a robot from 1985.

Code you can steal

Two files in the kit will send you the wrong way

Skip 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.

Connection limits

back to the app · driving a stock robot with your own brain instead: agent-docs · built something? drop a note in Discord