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
- Write the flow as a
.flow.jsonfile. - Run
node dist-cli/thingstudio-compile.mjs --board board:lolin-s2-mini --layout my.flow.json(the board ids areboard:cyd,board:freenove-s3-4in,board:lolin-s2-mini,board:pico,board:pico-2andboard:pico-w, orprocessor:esp32,esp32-c3,esp32-s2,esp32-s3,rp2040,rp2350for a bare chip;--list-boardsshows the current list). Read every error: it names the node and says what is wrong. Fix them all, and everylayout:warning, and run it again until it printsOK. - 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.pyinstead, 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_requestis plainhttp://only (no HTTPS), GET or POST. A reply with any status is passed on with an integermsg['status']; a failed connection or a timeout sends nothing on and logs an error. So testmsg['status'] == 200in afunction, and expect no message at all when the server is unreachable.mqtt_subscribepayloads arrive as text when they decode, otherwise bytes. For a number, usefloat(msg['payload'])in afunction.functioncode canimportstandard modules (json,framebuf,time) inside the body.http_ingives apayloadofNoneandhttp_responsesets noContent-Type. Use port 80 unless something else on the board already uses it.bme280has no input: it reads on its own interval. To answer a web request with a current value, keep the latest reading withflow.set('temp', ...)in afunctionafter the sensor, and read it withflow.get('temp')in afunctionbetweenhttp_inandhttp_response(send a 503 before the first reading arrives).- To print text on a
display_i2c(SSD1306), draw withframebufin afunctionand send the buffer: see display i2c. Its address property isaddr, a plain number (60is0x3C).
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
widthandheightare the panel's own size (320 × 480 for the Freenove 4") whatever the orientation. Portrait needs nothing more: leaveorientationempty and use the board'srotation(1 for the Freenove). To turn the picture, set theorientationproperty ofdisplay_spito"90","180"or"270"(clockwise), not the screen's size. - A navigate button's
targetisnext,prev,back,homeor thenameof a page inscreens; when you edit an existing flow, read the page names from itsscreenssection. - 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) andgridRows(0= from the display's shape). Every element hasrow,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,xstartandystartmust be-1, which means "no offset" (the CYD example flow uses 0, which an orientation refuses;-1is 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 inpages: 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.