Skip to content

Writing flows with an AI assistant

This page is for an AI assistant (or a person) writing a Thingstudio flow file or a custom node in the user's folder, without the editor. It is complete on its own, with two companions: the generated node catalog, and the thingstudio-compile command (Check a flow without the editor). With a board attached, device_cli.py deploys and runs the flow (Command line). The whole docs set is also one file at /llms-full.txt on the docs site, with an index at /llms.txt.

Setting up your assistant

Paste this into the assistant's standing instructions (a project's instructions, AGENTS.md, CLAUDE.md, or a custom GPT/Gem), so it reads this page before it writes a flow:

When I ask for a Thingstudio flow or custom node, first read the Thingstudio authoring guide:
https://docs.thingstudio.net/ai-authoring/ (the whole docs set is one file at https://docs.thingstudio.net/llms-full.txt).
Write the flow as a .flow.json file, run the thingstudio-compile check with --board for my board and
--layout, fix every error and layout warning until it prints OK, then tell me: the board id you checked against, which credential
names I must create, what you assumed about wiring, and what you could not check.
Never say a flow works on the board unless you deployed it there and saw its output; otherwise say it
passes the check. No HTTPS in http_request.

Claude users can add the Thingstudio skill instead: thingstudio-flow-author-<version>.zip, from the releases page, carries this guide, the node catalog, the examples and the check command, so the assistant needs nothing else. Upload the zip as a skill in Claude, or unzip it into ~/.claude/skills/ for Claude Code. Use the skill whose version matches your editor. Otherwise the check needs Node and the built thingstudio-compile.mjs.

The loop

  1. Write the flow as a .flow.json file.
  2. Run node dist-cli/thingstudio-compile.mjs --board board:lolin-s2-mini --layout my.flow.json (the board ids are board:cyd, board:freenove-s3-4in, board:lolin-s2-mini, board:pico, board:pico-2 and board:pico-w, or processor:esp32, esp32-c3, esp32-s2, esp32-s3, rp2040, rp2350 for a bare chip; --list-boards shows the current list). Read every error: it names the node and says what is wrong. Fix them all, and every layout: warning, and run it again until it prints OK.
  3. Tell the user to open the file in the editor (File → Open), choose the board, and deploy. If a board is attached and the user asks you to test it, deploy it with device_cli.py instead, and report what the board printed. That result is for that board only.

The check is the same compile the editor runs, so a flow that passes it opens and compiles in the editor. It can't check credentials, custom node packages or the hardware. Don't claim a flow works on a board unless you deployed it there and saw it run; otherwise say it passes the check.

The flow file

A JSON object. These keys matter:

Key What
formatVersion 1
flowName A name for the flow.
nodes [{ "id", "type", "properties" }]. id is any unique string. type is thingstudio/<kind>.
edges [[fromId, outputNumber, toId, inputNumber]]. Numbers start at 0, in the order the catalog lists the ports.
configs [{ "id", "type", "properties" }]: shared settings (I2C bus, WiFi, MQTT broker, touch panel).
screens The pages of any GUI screen, keyed by that node's id. See GUI.
panes, paneOf Optional. Tabs in the editor and which tab each node is on; leave them out and everything lands on one tab. When you edit a file that has them, put new nodes in paneOf too (copy the pattern).
layout { "<nodeId>": { "pos": [x, y] } }. Optional, but give positions: without them every node lands at 0,0.
notes Optional text shown in the editor's Notes panel. Say what the flow is for and how it is wired.

A property you leave out gets the default listed in the catalog. Use the catalog's names and types exactly: intervalMs, not interval. Values and ranges are checked by the compile, which says what is allowed.

Give every node a position, laid out as described in Layout.

A complete flow, a blinking LED on a LOLIN S2 Mini (its LED is pin 15):

{
  "formatVersion": 1,
  "flowName": "blink",
  "nodes": [
    { "id": "tick", "type": "thingstudio/timer", "properties": { "intervalMs": 500 } },
    { "id": "flip", "type": "thingstudio/function",
      "properties": { "code": "msg['payload'] = msg['payload'] % 2\nreturn msg", "outputCount": 1 } },
    { "id": "led", "type": "thingstudio/gpio_out", "properties": { "pin": 15 } }
  ],
  "edges": [["tick", 0, "flip", 0], ["flip", 0, "led", 0]],
  "layout": { "tick": { "pos": [80, 200] }, "flip": { "pos": [300, 200] }, "led": { "pos": [520, 200] } },
  "configs": []
}

Layout

The user reads the flow on the editor's canvas, and the editor shows it exactly where the file puts it. It does not rearrange the nodes or zoom to fit. Lay it out the way a careful person would:

  • Left to right. Sources (timers, inputs, subscriptions) on the left, outputs on the right, and every wire running to a node further right. Don't wrap a long chain back to the left edge on a second row.
  • Columns about 220 px apart, left edge to left edge. Nodes are 84-132 px wide, so that leaves about 90 px for the wire. Use the same column positions for the whole flow, so columns line up.
  • Tight rows. Nodes are 34 px tall (a function node with several outputs is taller). Stack branches in one column about 36 px apart (y about 70 apart), and separate chains about 66 px apart (y about 100 apart).
  • One chain per job, stacked top to bottom: inputs and processing first, display or output last. Unwired nodes (GUI buttons that only appear on a screen, for example) go together at the bottom left.
  • Straight wires. A node fed by one node sits at that node's y. When one node feeds several, the first sits level with it and the rest stack below; a node fed by several sits level with the first of them, or between them.
  • Start near (80, 80), so the flow is on screen when it opens.

--layout checks these rules and reports problems as layout: warnings. --relayout rewrites the positions to follow them, which makes a good first pass for a new flow. When you edit the user's own flow, keep their layout: place only the nodes you add, and don't relayout it unless they ask.

Which node

To... Use
send a message every so often, or once at start timer, startup, inject
read a BME280 / BMP280 bme280 (payload is a dict with temperature, humidity, pressure; pick one out in a function)
read another I2C chip i2c on a shared i2c-bus config
react to a switch or button eswitch (a debounced switch: open and close events); ebutton for press, release, long-press and double-click; interrupt for raw edges
drive a pin gpio_out (on/off), pwm_out
change or route a message function; filter to pass only changes; delay
keep a number's history journal, shown by a histogram (gui_trend)
show the time ntp (two for time and date: format time and date)
talk MQTT / HTTP / UDP mqtt_publish, mqtt_subscribe, http_request, http_in and http_response, udp_send, udp_receive
draw on a screen gui_screen with gui_* widgets, wired to display_spi

Messages and the function node

Every message is a dict: msg = {'payload': ..., 'topic': ...}. payload is what nodes read; topic is a string, '' when there is nothing to say. Other keys ride along.

A function node's code is the body of a function in MicroPython (a subset of Python with a small standard library). Return msg to pass it on, or None to stop. With outputCount above 1, return a list with one entry per output (None for nothing, a list of messages for several). context keeps state for the node, flow for the whole flow. See function. A syntax error in the code is caught by the check.

A wire is refused if the payload types can't meet (for example a bool into a bytes input). The check reports it. Numbers go into bool inputs (zero is off), and an int into a number; a number into an int is refused.

Addresses are written two ways, because the nodes differ: the bme280 address is a plain number (118 is 0x76); a touch panel config's address is text ("0x38"). The catalog notes which.

A node that uses the network (mqtt_publish, http_request, http_in and so on) uses the flow's WiFi: add a WiFi config to configs and it is picked up; the node has no WiFi property of its own.

Things to know before you pick a node:

  • http_request is plain http:// only (no HTTPS), GET or POST. A reply with any status is passed on with an integer msg['status']; a failed connection or a timeout sends nothing on and logs an error. So test msg['status'] == 200 in a function, and expect no message at all when the server is unreachable.
  • mqtt_subscribe payloads arrive as text when they decode, otherwise bytes. For a number, use float(msg['payload']) in a function.
  • function code can import standard modules (json, framebuf, time) inside the body.
  • http_in gives a payload of None and http_response sets no Content-Type. Use port 80 unless something else on the board already uses it.
  • bme280 has no input: it reads on its own interval. To answer a web request with a current value, keep the latest reading with flow.set('temp', ...) in a function after the sensor, and read it with flow.get('temp') in a function between http_in and http_response (send a 503 before the first reading arrives).
  • To print text on a display_i2c (SSD1306), draw with framebuf in a function and send the buffer: see display i2c. Its address property is addr, a plain number (60 is 0x3C).

Pins: take them from the board

Never guess a pin. List the boards with --list-boards, run --board-info board:<id> for what each pin does (the LED, the buttons, the display, the I2C pins) and which to avoid, and check the finished flow with --board board:<id>. If the user's board is not listed, say so, ask for the pins, and point them to the MicroPython documentation for their board (docs.micropython.org). Don't pretend a board is supported.

Things the pin check cannot catch: a pin that exists but is wired to something else on the user's board. When the user has told you the wiring, use it; when they haven't, say what you assumed.

Config nodes and credentials

A node that needs shared settings names a config by id: "i2cConfigId": "cfg-i2c", with the config in configs:

{ "id": "cfg-i2c", "type": "thingstudio/config/i2c-bus", "properties": { "bus": 0, "scl": 15, "sda": 16, "freq": 100000 } }

The catalog lists each config type's fields. WiFi and MQTT configs hold a credential name:

{ "id": "cfg-wifi", "type": "thingstudio/config/wifi", "properties": { "credentialName": "home", "security": "password" } }

Never write a password, SSID or broker secret into a flow file. Write a credentialName and tell the user which names to create in the editor's credential store (they are saved there, not in the file). The check uses stand-ins for them and says so.

GUI screens

A touch or plain display is two nodes wired together, gui_screen then display_spi, plus widget nodes (gui_label, gui_readout, gui_bar, gui_trend, gui_led, gui_button) that you wire data into. Where each widget sits is in screens:

"screens": { "<screenId>": { "pages": [
  { "name": "main", "elements": [
    { "card": true, "row": 0, "col": 0, "rowSpan": 1, "colSpan": 12 },
    { "kind": "text", "text": "Living room", "row": 0, "col": 0, "colSpan": 8 },
    { "kind": "widget", "node": "<readoutId>", "row": 1, "col": 0, "rowSpan": 4, "colSpan": 12 },
    { "kind": "pagedots", "row": 15, "col": 4, "colSpan": 4 }
  ] }
] } }

Rules that cause most failures:

  • The screen's width and height are the panel's own size (320 × 480 for the Freenove 4") whatever the orientation. Portrait needs nothing more: leave orientation empty and use the board's rotation (1 for the Freenove). To turn the picture, set the orientation property of display_spi to "90", "180" or "270" (clockwise), not the screen's size.
  • A navigate button's target is next, prev, back, home or the name of a page in screens; when you edit an existing flow, read the page names from its screens section.
  • Fonts are chosen to fit an element's cells; nothing in a layout is in pixels. A character no font has is an error.
  • A widget is placed once per page, drawn the same on every page it is on. Its cells decide its size.
  • A page is a cell grid: the gui screen has gridColumns (12 by default) and gridRows (0 = from the display's shape). Every element has row, col (0-based) and spans; an element outside the grid, two on the same cells, or one whose text cannot fit is an error naming it and the page.
  • With an orientation, xstart and ystart must be -1, which means "no offset" (the CYD example flow uses 0, which an orientation refuses; -1 is always allowed). At "90" or "270" the picture is the panel turned, so a 320 x 480 panel gives 480 wide by 320 high to lay out.
  • A modal is laid out in screens.<screenId>.modals, not in pages: see the format below.
  • A histogram (gui_trend) takes a journal's first output, and a journal takes numbers: sensor → journal → histogram.

A modal (gui_modal, with screen set to the gui_screen node's id) is listed next to pages. Its elements are what it shows, and not the modal node itself:

"modals": [ { "node": "<modalNodeId>", "elements": [
  { "kind": "text", "text": "Too hot", "row": 0, "col": 0, "rowSpan": 2, "colSpan": 8 },
  { "kind": "widget", "node": "<labelId>", "row": 2, "col": 0, "rowSpan": 2, "colSpan": 12 }
] } ]

Any message opens it; a message with payload None closes it; timeout (seconds, 0 for never) closes it by itself; a navigate button with target back acknowledges it. Its output sends 'ack', 'timeout' or 'closed'.

Placement keys, in screens (a page is {"name", "elements": [...]}; a modal is {"node", "elements": [...]}):

Element Keys
any element row, col (0-based cells), rowSpan, colSpan (default 1), card (draw a panel behind), align
widget kind: "widget", node (the widget node's id)
text kind: "text", text
page dots kind: "pagedots"
panel card: true (no node or text)

No element takes a font, height, width, gap or padding: fonts follow the cells and spacing is the screen's gutter. The button's own behaviour (mode, target, onValue, ...) is on the node: see the catalog. For a toggle that follows a real device (a smart plug), gui.md has the wiring.

Read GUI nodes for the details, and copy from test-flows/: gui-headliner-sensor-freenove-s3-4in.flow.json (a sensor display with histograms, a clock and MQTT), gui-touch-freenove-s3-4in.flow.json (buttons), gui-orientation-test-freenove-s3-4in.flow.json.

Custom nodes

If no node does the job and a function node is not enough, write a custom node: <name>.node.json and <name>.node.py. Writing custom nodes is complete on its own. The two files go in ~/.thingstudio/custom-nodes/ on the computer running Thingstudio. The check command can't check them: read them against that page, and test the Python body on its own with sample messages.

Examples to copy

Task File in test-flows/
Blink an LED blink.flow.json
Publish to MQTT basic-mqtt.flow.json
Call a web service basic-http-request.flow.json
A BME280 reading bmp280-test-flow.flow.json
A sensor on a display, with histograms and a clock gui-headliner-sensor-freenove-s3-4in.flow.json
Touch buttons gui-touch-freenove-s3-4in.flow.json

When you hand the flow back

Tell the user, briefly: that it passes the check (and with which --board), which credential names to create, which board to choose in the editor, what you assumed about wiring, and what you could not check.