Drive a GrowBot with your own agent

The robot's phone stays the safety layer; your brain sends high-level verbs. The web app validates each verb against the robot's body truth, clamps every number, enforces a motion duty budget, and runs the walk policy and reflexes (dead-man, tip-abort) in the browser at full rate. In the intended flow your agent sends verbs and never touches servos, firmware, or the raw wire protocol (see the safety notes at the bottom for the trust model).

your agent ──verbs──▶ relay ──▶ GrowBot web app (clamps · body truth · reflexes) ──▶ relay ──▶ chip ──▶ legs

Open growbot.dev/tester → Connect robot → pair → pick Custom agent. One field, two ways in:

The verb menu

verbparamswhat happens
speak(text="…")text ≤ 180 charsspoken aloud through the robot's phone
forward(meters=0.25)meters 0.05–2trained walk gait runs in the browser; ~0.05 m/s. Meters are an estimate — there is no odometry
turn(degrees=45)degrees −180…180turns in place (the app's spin; an arc-turn while stepping where spins are unavailable); negative=left, positive=right, approximate
stop()—halt everything; legs go limp
gesture(name="wiggle")wiggle · bow · swaya canned expressive move from body truth

Out-of-range numbers are clamped (the ack says clamped:true). Off-menu verbs and unknown params are rejected — the robot does nothing. One motion at a time; a new motion verb replaces the current one.

The minimal agent prompt

Give your model this as its system prompt (the app sends the canonical copy — including your robot's real gesture list — in the menu envelope and uses the same text in endpoint mode):

You drive a small two-legged desk robot (GrowBot) by replying with exactly ONE verb call per turn.

BODY: two-leg desk walker. Two paddle legs that swing from the hips. It can walk, turn,
gesture, and speak aloud through its phone. No arms, no wheels, no odometry.

VERBS (the complete menu; anything else is rejected):
  speak(text="...")                 say it aloud (max 180 chars)
  forward(meters=0.05..2)           walk forward; ~0.05 m/s, meters are an estimate not a measurement
  turn(degrees=-180..180)           turn in place (an arc-turn where spins are unavailable); negative = left, positive = right (approximate)
  stop()                            halt everything, legs go limp
  gesture(name=wiggle|bow|sway)     a canned expressive move

RULES:
- Reply with ONLY the verb call on one line. No prose, no markdown, no code fences, no explanation.
- One verb per turn. Each turn you receive a fresh observation (sensors + the result of your last verb).
- Out-of-range numbers are clamped; off-menu verbs are rejected and the robot does nothing.
- A safety layer enforces servo limits and a motion duty budget (about 20s of leg motion per minute).
  If an ack says the budget is spent, speak or wait instead of moving.

Test your local model first (curl)

Before wiring anything up, prove your model emits clean verbs. Ollama example:

curl -s http://localhost:11434/v1/chat/completions -H 'content-type: application/json' -d '{
  "model": "qwen3:4b",
  "messages": [
    {"role":"system","content":"<the minimal agent prompt above>"},
    {"role":"user","content":"observation: {\"robot\":1,\"duty_left_ms\":20000}\ngoal from the human: say hello, then take a short walk.\nReply with exactly ONE verb call."}
  ],
  "max_tokens": 120
}'
# good reply:  {"choices":[{"message":{"content":"speak(text=\"hello! stretching my legs.\")"}}, …

Then paste http://localhost:11434/v1 into the Custom agent field. For the browser to reach Ollama, start it with OLLAMA_ORIGINS='*' ollama serve (CORS). LM Studio: enable CORS in the server settings.

"curl works, but the app says it can't reach my endpoint"

This is expected, not a contradiction — curl and the browser are not the same client. The app's page makes the outbound call, and browsers enforce two rules curl has no concept of:

Mixed content. This page is served over https. An https page can fetch http:// only when the host is localhost or 127.0.0.1 — Chrome allows that one exception, Safari does not even allow that. Anything else — a LAN IP, a hostname, a public server — over plain http:// is refused before the request leaves the browser. Your model server running on your own machine and reachable via curl http://192.168.x.x:1234/... is invisible to this page for exactly that reason. Fix: put an https front door on it (a Cloudflare Tunnel or ngrok http 1234 both work) and paste the https URL instead, or skip this entirely with inbound mode below.

Once the request does leave the browser, CORS is the second gate: LM Studio needs its server-tab CORS toggle on; Ollama needs OLLAMA_ORIGINS='*'. A missing/blocked model server, a wrong port, and a CORS rejection all surface as the same bare network error, so if the endpoint field is a real https/localhost URL and the app still can't see it, check CORS next.

Using ngrok specifically: ngrok's free tier serves an HTML "you are about to visit this site" warning page to any request that doesn't send ngrok-skip-browser-warning: true — this app sends that header automatically, but if you're curling through the same tunnel to sanity-check it yourself, add -H 'ngrok-skip-browser-warning: true' or you'll see the warning page's HTML instead of LM Studio's JSON.

Empty model list? /v1/models reports whatever LM Studio currently has loaded — if nothing is loaded/selected in its UI, the list is legitimately empty and the dropdown will say so.

Inbound handshake (your own script)

Your agent connects to the shared relay on the robot's agent side-channel — the pairing code plus -agent. The web app must be open on the Custom agent tab (it is the safety layer; no app, no motion).

directionenvelopemeaning
you →{"t":"attach","id":"<code>-agent","code":"<code>-agent"}join the channel (first frame)
← you{"t":"status","awake":0|1}is the web app listening
you →{"t":"hi","name":"my-agent"}ask for the menu
← you{"t":"menu","menu":[…],"body":{…},"prompt":"…"}verbs, body truth, ready-made system prompt
← you{"t":"obs",…} at ~2 Hzsensor ticker: robot online, IMU, duty budget, last action
you →{"t":"verb","rid":1,"action":"forward(meters=0.3)"}act (or "verb":"forward","params":{…})
← you{"t":"ack","rid":1,"ok":true,"note":"…","duty_left_ms":…}result, after the motion finishes
you →{"t":"ping"} → {"t":"pong"}keepalive (send something every ≤20s to stay "connected" on the status screen)

Minimal Python agent (pip install websockets):

import asyncio, json, websockets

RELAY = "wss://growbot-relay.growbot.workers.dev"
CODE  = "gb-XXXXXX"                    # your robot's pairing code
CHAN  = f"{RELAY}/d/{CODE}-agent"

async def main():
    async with websockets.connect(CHAN) as ws:
        await ws.send(json.dumps({"t":"attach","id":f"{CODE}-agent","code":f"{CODE}-agent"}))
        await ws.send(json.dumps({"t":"hi","name":"demo"}))
        rid = 0
        async for raw in ws:
            m = json.loads(raw)
            if m.get("t") == "menu":
                print("verbs:", [v["verb"] for v in m["menu"]])
                rid += 1
                await ws.send(json.dumps({"t":"verb","rid":rid,"action":'gesture(name="wiggle")'}))
            elif m.get("t") == "ack":
                print("ack:", m)        # ...decide the next verb here (or ask an LLM to)
            elif m.get("t") == "obs":
                pass                    # ~2 Hz sensors: m["robot"], m["orientation_deg"], m["duty_left_ms"]

asyncio.run(main())

Quick poke without code (brew install websocat): run websocat wss://growbot-relay.growbot.workers.dev/d/<code>-agent, paste the attach line, then a verb line.

Safety model — what the app enforces, honestly

← back to the app · local models guide: LOCAL_MODELS.md · putting your own hardware on the other end: body-docs (experimental)