LoopIT

LoopIT parameter server — the JSON/TCP control interface

neuroConn GmbH

2026-06-05

Introduction

The parameter server is the LoopIT’s host-facing control channel. A program on your PC opens a TCP connection and exchanges JSON messages to read and write the parameters of the device’s modules — for example to configure a stimulation waveform, turn stimulation on or off, or deploy a real-time script.

This document is a practical guide for someone who has never used the interface before but is comfortable with TCP/IP sockets and JSON. It describes the protocol as the device actually implements it. Because every LoopIT carries a different set of hardware and firmware modules, the concrete module and parameter names you see here are illustrative — the discovery section shows how to ask a specific device what it supports.

For the bigger picture and the other two interfaces (onboard scripting and LSL monitoring), start at the overview document. For a terse, structured listing of every message (request, reply, example), see the JSON command reference.

Transport

The server listens on TCP port 1219 over IPv4. Connect, send one JSON object, read one JSON reply, and repeat. A few properties are worth knowing up front:

  • One client at a time. The server accepts a single connection and processes one message before it is ready for the next. There is no multiplexing; a second client cannot connect while one is paired.
  • No length prefix or delimiter. A message is complete when the bytes received so far form a valid JSON value. Your client should keep reading and appending until its JSON parser accepts the buffer (see a complete client).
  • ASCII only. Messages must be ASCII. A non-ASCII message is rejected with the error string message-contains-non-ascii-characters.
  • Size limit. Messages larger than roughly 128 KB are rejected with message-larger-than-allowed. This is generous for parameters and comfortably fits a real-time script.
  • Timeouts. Socket reads and writes time out after one second, so send each message in one write and read the reply promptly.

If you send a message before the device has finished booting its modules, you get a reply whose value is [null, "still-booting"]. Wait briefly and retry.

Authentication

By default the server only accepts messages after authentication. The operator enables remote control on the device’s touchscreen, which then displays a six-digit one-time password. Include that password as a top-level token field on every message:

{ "token": "<one-time-password>", "current_source": { "0": { "stimulation": true } } }

The first client to present a valid token pairs with the device by its source IP address; after that, other clients are refused unless they share the same IP (for example behind the same router). Disabling remote control on the touchscreen unpairs the device.

Two token errors can come back, both shaped as [null, "<reason>"]:

  • token::invalid — the token does not match the one shown on screen.
  • token::deprecated — your IP was paired, but the token has since changed (for example the operator regenerated it); you must pair again.

After several invalid attempts the device temporarily refuses further tokens from that IP, so do not retry in a tight loop.

Message model

Every message is a JSON object. As required by JSON, all keys are strings. A request addresses one or more parameters using three nested levels:

  1. the module name (for example current_source),
  2. the instance index as a quoted string ("0" for the first instance — note the quotes; a bare 0 is not valid JSON for a key),
  3. the parameter name and its value.

To set a parameter, give it a value:

{ "current_source": { "0": { "stimulation": true } } }

To read a parameter, use null as the value:

{ "current_source": { "0": { "stimulation": null } } }

Some modules have modes, where each mode brings its own group of parameters. Modes add one nesting level — the mode name sits between the index and the parameters:

{ "current_source": { "0": { "tdcs": { "amplitude": 1000000 } } } }

You may address several parameters, several instances, and several modules in one message by listing them side by side.

Replies

Every message receives a reply. These reply shapes are server output, so they are shown here as plain text rather than as runnable request examples.

A valid set or read echoes the parameter back with its accepted value:

{"current_source": {"0": {"stimulation": true}}}

An invalid key or value is reported in one of two forms — either the offending key carries a bare null, or it carries a two-element array whose second entry is a short reason:

{"current_source": {"0": {"stimulation": null}}}
{"current_source": {"0": {"amplitude": [null, "invalid_value"]}}}

Because a single message can mix valid and invalid parameters, the reply is per-parameter: the parts that were accepted echo their values, and only the offending parts carry an error. The reason strings the device uses include:

write_protected, read_protected, not_available, invalid_token, invalid_syntax, invalid_module, invalid_index, invalid_field, invalid_value, invalid_mode, invalid_mode_syntax, too_few_fields, too_many_fields, too_many_values, too_few_values, missing_required_field, and mixed_getters_and_setters.

If a reply comes back with a bare null, treat it as “the device tried to ignore this”: the safest recovery is to read the current settings back and reconcile before continuing.

Discovering what a device offers

Because each device is configured differently, do not hard-code module and parameter names — ask the device. The configuration query returns the full picture: every module, every instance, and the parameters each accepts, together with their metadata.

{ "?": { "!": "conf" } }

The reply (server output) is a nested object of the same module → index → parameter shape you use for requests, so you can read it to learn which modules exist and what to send. A lighter variant returns the last cached configuration without refreshing it:

{ "?": "conf" }

You can also list the available module sockets, refreshing first or using the cache:

{ "?": { "!": "sockets" } }
{ "?": "sockets" }

The recommended workflow is discover, then act: query the configuration once at start-up, confirm the modules and parameters you need are present, and only then send your control messages. This keeps your client working across devices with different hardware.

Wildcards

A "*" key matches everything at its level and is handy for bulk reads. Read every parameter of every module:

{ "*": null }

Read every parameter of one module instance:

{ "current_source": { "0": { "*": null } } }

Stimulation example

The current source is the module that generates stimulation. The exact modes and parameters are device-specific, so treat the following as illustrative and confirm the real schema by discovery. A direct-current configuration typically looks like this:

{
  "current_source": {
    "0": {
      "tdcs": {
        "amplitude": 1000000,
        "duration": 6000,
        "fade_in": 3000,
        "fade_out": 3000,
        "fade_in_type": "linear",
        "fade_out_type": "linear"
      }
    }
  }
}

Stimulation is off by default. After the parameters are set, a separate command starts it, and the same field stops it:

{ "current_source": { "0": { "stimulation": true } } }
{ "current_source": { "0": { "stimulation": false } } }

Whether parameters can be changed while stimulation is running depends on the module and mode in use; many require stopping, setting, and restarting. Combined with the latency and jitter of a host-PC link, this path is not suited to fast real-time changes — for closed-loop control, use an onboard script (see the scripting guide) and let the host set only its high-level parameters.

Deploying a real-time script

An onboard real-time script is deployed over this same connection by writing the script text, as a string, to the .protocol field of the ral module:

{ "ral": { "0": { ".protocol": "1w interface{1w reserved;} ral; script{};" } } }

You can read back the script the device is currently running:

{ "ral": { "0": { ".protocol": null } } }

On success the reply (server output) echoes the script and, when the device has a timing model available, adds an estimated_runtime with the script’s per-cycle cost at the 50th, 90th and 99th percentiles in microseconds:

{"ral": {"0": {".protocol": "...", "estimated_runtime": {"p50_us": 7, "p90_us": 9, "p99_us": 12}}}}

If the script does not compile, the reply carries the compiler’s message:

{"ral": {"0": {".protocol": [null, "2:9: error[E000]: syntax error ..."]}}}

A script can also be refused because it would not fit the real-time budget, in which case the reason starts with Script exceeds RT budget. A related field, .cycle_time_in_ms, selects which engine runs the script: a value of zero or less selects the 1 ms real-time engine, while a positive value selects the slower, event-driven engine. Set it before deploying the script. The scripting guide covers how to write the script itself.

A complete client

Because messages are framed by JSON validity rather than a delimiter, a client reads and appends bytes until the buffer parses. The same small client in three languages — each connects, attaches the token, sends a request, and reads bytes until the reply parses. None needs more than the standard library (plus a JSON library in C++).

Python

import json
import socket


class LoopIT:
    """Minimal client for the LoopIT parameter server (JSON over TCP)."""

    def __init__(self, host, token, port=1219, timeout=5.0):
        self.token = token
        self.sock = socket.create_connection((host, port), timeout=timeout)

    def request(self, message):
        """Send one message (the token is added automatically) and return the reply."""
        message = {"token": self.token, **message}
        self.sock.sendall(json.dumps(message).encode("ascii"))
        buf = b""
        while True:                                  # a reply is complete once it parses
            chunk = self.sock.recv(4096)
            if not chunk:
                raise ConnectionError("connection closed by device")
            buf += chunk
            try:
                return json.loads(buf)
            except json.JSONDecodeError:
                continue

    def configuration(self):
        return self.request({"?": {"!": "conf"}})

    def set(self, module, index, field, value):
        return self.request({module: {str(index): {field: value}}})


if __name__ == "__main__":
    device = LoopIT("192.0.2.10", token="123456")   # device IP and touchscreen token
    print(device.configuration())                    # learn what this device supports
    print(device.set("current_source", 0, "stimulation", False))

JavaScript

const net = require("net");

function connect(host, token, port = 1219) {
  const sock = net.createConnection({ host, port });
  let buf = "";
  let pending = null;
  sock.on("data", (chunk) => {
    buf += chunk;
    try {                                  // a reply is complete once it parses
      const reply = JSON.parse(buf);
      buf = "";
      const resolve = pending; pending = null; resolve(reply);
    } catch (e) { /* keep reading */ }
  });
  return {
    request: (message) =>
      new Promise((resolve) => {
        pending = resolve;
        sock.write(JSON.stringify({ token, ...message }));
      }),
    close: () => sock.end(),
  };
}

(async () => {
  const device = connect("192.0.2.10", "123456");
  console.log(await device.request({ "?": { "!": "conf" } }));
  console.log(await device.request({ current_source: { "0": { stimulation: false } } }));
  device.close();
})();

C++

// requires nlohmann/json (https://github.com/nlohmann/json)
#include <nlohmann/json.hpp>
#include <arpa/inet.h>
#include <unistd.h>
#include <string>

using json = nlohmann::json;

class LoopIT {
  int fd;
  std::string token;
public:
  LoopIT(const std::string& host, std::string token, uint16_t port = 1219)
      : token(std::move(token)) {
    fd = ::socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    ::inet_pton(AF_INET, host.c_str(), &addr.sin_addr);
    ::connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
  }

  json request(json message) {
    message["token"] = token;
    auto out = message.dump();
    ::send(fd, out.data(), out.size(), 0);
    std::string buf;
    char chunk[4096];
    for (;;) {                              // a reply is complete once it parses
      auto n = ::recv(fd, chunk, sizeof(chunk), 0);
      if (n <= 0) throw std::runtime_error("connection closed");
      buf.append(chunk, n);
      if (json::accept(buf)) return json::parse(buf);
    }
  }

  ~LoopIT() { ::close(fd); }
};

int main() {
  LoopIT device("192.0.2.10", "123456");
  auto conf = device.request({{"?", {{"!", "conf"}}}});
  device.request({{"current_source", {{"0", {{"stimulation", false}}}}}});
}

Start from the configuration query to learn what a specific device supports, then use get/set against the modules and fields it reports. A more complete reference client is also available from neuroConn on request.

Troubleshooting

  • No reply, or the read times out. Another client may be paired (the server serves one at a time), or you sent invalid JSON. Reconnect and confirm your message parses locally first.
  • [null, "still-booting"]. The device is still bringing up its modules; wait and retry.
  • token::invalid / token::deprecated. Re-read the one-time password from the touchscreen and pair again; avoid rapid retries, which trigger a temporary block.
  • message-contains-non-ascii-characters. Encode messages as ASCII.
  • A bare null value in the reply. The device ignored that part; read the current settings back to confirm device state before continuing.

Frequently asked questions

Is there full documentation of all JSON commands, including for tES and FES? This document describes the protocol; the concrete parameters depend on your device’s modules and are best read from the device itself via the configuration query. A complete command reference for your configuration is available from neuroConn on request.

What port does the parameter server use? TCP 1219 over IPv4. See Transport.

Do I have to stop stimulation before changing parameters? It depends on the module and mode; many require a stop, a parameter set, and a restart, and this is not suited to fast real-time changes over the host link. See Stimulation example and use an onboard script for closed-loop work.

Does the connection need a handshake? There is no session handshake, but by default every message must carry the one-time-password token. See Authentication.

Can I select an application and configure it, instead of raw hardware parameters? The interface addresses modules by name and index; there is no separate application selector. For application-specific real-time behaviour, deploy an onboard script (see Deploying a real-time script), which you send over this same connection.

References

LoopIT documentation · neuroConn