Guide

Build your own HMI

Register a standalone OIDC application and call the Hub's GraphQL API from your own login screen and dashboard — the same API the control plane uses.

Switch to Khorde Cloud to see the same HMI pointed at the hosted platform instead.

This picks up where Connect a REST agent left off — a Hub with an agent already reporting data. The steps below don't depend on the agent being REST specifically, just on there being something to query.

01
Register your app

The control plane is for provisioning — a real product needs its own HMI. The Hub doesn't care what that HMI is built with, only that it shows up with a valid token from its own registered app. Same idea as the agent's service account, but for a human-facing app: Apps → New app in the control plane. Redirect URI is wherever your app runs; add the same URI under Post-logout redirect URIs too — sign-out fails without it — and turn on Dev mode for a plain http://localhost URI. It's a public client (PKCE), so there's no secret to protect.

The New app dialog filled in for a Demo HMI: redirect URI, matching post-logout redirect URI, and Dev mode enabled
A minimal reference

khorde-io/hmi-starter is a small React + TypeScript app doing exactly this — a login screen and a dashboard running the queries and subscription from this guide. Clone it, drop in your own client ID, and it works against your local Hub as-is.

Log in

oidc-client-ts handles the redirect and the PKCE exchange — this is the whole login side, no backend involved:

auth.tstypescript
const AUTHORITY = "http://auth.localhost:9310"; // your Hub's IdP
const CLIENT_ID = "…";                         // from Apps → New app
const REDIRECT_URI = `${window.location.origin}/`;

// These two put the SCADA project (and role grants) on the access token —
// without them the Hub's GraphQL API rejects it.
const SCOPES = [
  "openid",
  "profile",
  "email",
  "urn:zitadel:iam:org:project:id:khorde_scada:aud",
  "urn:zitadel:iam:org:projects:roles",
];

export const userManager = new UserManager({
  authority: AUTHORITY,
  client_id: CLIENT_ID,
  redirect_uri: REDIRECT_URI,
  post_logout_redirect_uri: REDIRECT_URI,
  response_type: "code",
  scope: SCOPES.join(" "),
  userStore: new WebStorageStateStore({ store: window.localStorage }),
});
The demo HMI's login screen: a Sign in with Khorde ID button
Call the API

Queries and mutations are a fetch with a Bearer header, same as curl earlier in this guide. Subscriptions go over graphql-ws against that exact same URL, just upgraded to a WebSocket — the one gotcha is that the Hub reads the token from the WebSocket's connectionParams as { token }, not an Authorization header:

graphql.tstypescript
// Queries and mutations: a plain POST with the token as a Bearer header.
const res = await fetch("http://localhost:8085/", {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
  body: JSON.stringify({ query, variables }),
});

// Subscriptions: graphql-ws against the same URL, upgraded — the Hub reads
// the token from connectionParams as { token }, not an Authorization header.
const client = createClient({
  url: "ws://localhost:8085/",
  connectionParams: () => ({ token }),
});
client.subscribe({ query, variables }, { next: (result) => {/* ... */ } });
The demo HMI's dashboard after signing in: a registeredMeasurements query result for breaker-1 status, and a live agentHeartbeatSubscribe subscription showing status online
Next

Connect more agents

Every agent — REST or otherwise — is provisioned the same way, so your HMI keeps working as you add more. Connect a REST agent walks through creating and configuring one.

Explore the GraphQL API

Everything an HMI needs — measurements, alarms, commands — is served over GraphQL on the same :8085. Schema reference is on the Docs page.

Taking this past your laptop?

Talk to us