Guide

Agent configuration

The schema behind the JSON or YAML you upload to an agent — measurements, alarms, commands, and interlocks. Same shape for every protocol; only the driver block changes.

This is the reference for the config introduced in Connect a REST agent — worth a read once you're past the first measurement and alarm. One file, top-level keyed by agent name, the same whether it covers one agent or ten:

config
{
  "my-agent": {
    "type": "client",
    "protocol": "rest",
    "measurements": [ /* ... */ ],
    "alarms": [ /* ... */ ],
    "commands": [ /* ... */ ],
    "interlocks": [ /* ... */ ]
  }
}

type is client for agents that read from equipment (REST, an IEC 60870-5-104 client) or server for agents that expose Khorde data to a field protocol (an IEC 60870-5-104 server). protocol picks the shape of every driver block below it.

For inline validation and autocomplete while you edit, point your editor at the published schema: https://schemas.khorde.io/v1/agent-config.json (or the .yaml variant).

01
Measurements

A point read off the equipment. equipment and name together are its stable identity — written equipment::name — and that's how alarms, interlocks, and GraphQL queries all refer back to it.

  • type — discrete for boolean or multi-state values, analogical for a continuous reading.
  • store — keep this measurement's history for later querying. Leave it off for values you only need live.
  • tags — free-form strings for grouping and search; no schema meaning.
  • driver — a signal type (digital, discrete, or analogical), an optional conversion, and the protocol's own address fields — see the driver reference for REST, IEC 60870-5-104, or IEC 61850.

conversion maps the raw value off the wire to the value Khorde stores, before anything else sees it:

  • linear — { factor, offset }, applied as value * factor + offset. Scale raw counts to engineering units.
  • table — { table: { raw: mapped } }, an exact lookup. Anything not listed passes through unchanged.
  • interval — { interval: [{ from, to, value }] }, half-open [from, to) buckets. First match wins; unmatched values pass through unchanged.

A raw 0–1000 count converted to °C with linear:

measurement
{
  "name": "temperature",
  "equipment": "eqp",
  "type": "analogical",
  "store": true,
  "driver": {
    "type": "analogical",
    "path": "14",
    "conversion": { "type": "linear", "factor": 0.1, "offset": -40 }
  }
}

A four-position switch collapsed down to open/closed with table — raw 0 and 2 both read as closed, 1 and 3 as open:

measurement
{
  "name": "status",
  "equipment": "eqp",
  "type": "discrete",
  "store": true,
  "driver": {
    "type": "discrete",
    "path": "16",
    "conversion": {
      "type": "table",
      "table": { "0": 0, "1": 1, "2": 0, "3": 1 }
    }
  }
}

A raw 0–100 tap position bucketed into low/mid/high bands with interval:

measurement
{
  "name": "tap_position",
  "equipment": "eqp",
  "type": "discrete",
  "store": true,
  "driver": {
    "type": "discrete",
    "path": "17",
    "conversion": {
      "type": "interval",
      "interval": [
        { "from": 0, "to": 34, "value": 0 },
        { "from": 34, "to": 67, "value": 1 },
        { "from": 67, "to": 101, "value": 2 }
      ]
    }
  }
}
Alarms

Evaluated off a measurement's value, referenced by meas as equipment::name. Two shapes, picked by type.

digital — one threshold in each direction: set and reset are the raw values that arm and clear it, with a single severity. Good for trip / no-trip style points.

alarm
{
  "name": "trip_brk_10",
  "area": "brk_10",
  "type": "digital",
  "set": 1,
  "reset": 0,
  "severity": "high",
  "meas": "eqp::brk_10"
}

discrete — a list of independent conditions off the same measurement, each with its own value, state (Set, Reset, or Event), and severity. Good for equipment with more than two meaningful states.

alarm
{
  "name": "trip_brk_41",
  "area": "brk_41",
  "type": "discrete",
  "meas": "eqp::brk_41",
  "conditions": [
    { "id": "cond_1", "value": 1, "state": "Set", "severity": "low" },
    { "id": "cond_2", "value": 2, "state": "Reset", "severity": "medium" },
    { "id": "cond_3", "value": 3, "state": "Event", "severity": "high" }
  ]
}
Commands

A writable operation on a piece of equipment. Like measurements, driver carries the protocol-specific address plus the value to send. interlock is optional — omit it and the command runs unconditionally for anyone with permission to send it; include it to require a condition first:

command
{
  "name": "pos.open",
  "equipment": "brk_10",
  "driver": { "type": "discrete", "value": 1, "path": "1" },
  "interlock": { "key": "brk_10_safe" }
}
Interlocks

A reusable boolean condition, referenced from a command by key. The expression is a small logic tree: and, or, and not combine child nodes; eq, ne, gt, ge, lt, and le compare two operands, each either a measurement reference (equipment::name) or a plain number. ref inlines another interlock by its key, so shared logic is defined once:

interlocks
{
  "interlocks": [
    {
      "key": "brk_10_safe",
      "description": "Only allow open below 100kW, or if already cleared",
      "expression": {
        "or": [
          { "lt": ["eqp.brk_d::power", 100] },
          { "ref": "load_cleared" }
        ]
      }
    },
    {
      "key": "load_cleared",
      "description": "Reusable: both feeders are open",
      "expression": {
        "and": [
          { "eq": ["eqp::brk_20", 0] },
          { "eq": ["eqp::brk_30", 0] }
        ]
      }
    }
  ]
}
02
Running the image

Every agent image runs the same way: mount this config and the service account key from the control plane, publish whatever port it listens on, and give it a route to the Hub and IdP on your host if you're running locally. Only the image and its published port change per protocol:

docker-compose.yaml
services:
  agent:
    image: khorde/<agent-image>:latest
    ports:
      - "<port>:<port>"
    volumes:
      - ./config.yaml:/opt/agent/config.yaml
      - ./service-user.json:/opt/agent/service-user.json
    extra_hosts:
      - "host.docker.internal:host-gateway"
      - "auth.localhost:host-gateway"

extra_hosts is only there to route the .localhost names through Docker's network — against Khorde Cloud there's nothing to route, since it's already public DNS. Each protocol page below has the concrete image name, port, and its own runtime config.yaml — the settings that talk to the field, not to Khorde.

Next

Everything above is shared; only the driver address fields change per protocol. Pick yours:

REST

REST agent — HTTP in, HTTP out.

IEC 60870-5-104 (client)

IEC 104 client — reads a field device in.

IEC 60870-5-104 (server)

IEC 104 server — exposes SCADA data out.

IEC 61850

IEC 61850 agent — MMS objects on IEDs.

Taking this past your laptop?

Talk to us