Skip to content

guides

Intended Documentation

C++ Industrial Controls

Integrate Intended into TwinCAT 3, Studio 5000, TIA Portal, or custom industrial PCs via the cloud HTTP API and a non-RT mediator process.

PartialSource: codeValidated: 2026-06-07

Integrating Intended in C++ Industrial Controls#

Audience: controls engineers writing C++ for TwinCAT 3 / Beckhoff ADS, Rockwell ControlLogix add-on instructions, Siemens Open Controller / TIA Portal blocks, or custom industrial PCs.

The C++ SDK is Roadmap

There is no shipped C++ SDK today. This guide describes the canonical HTTP shape of the cloud API plus the recommended integration pattern. Industrial-controls C++ is fragmented across vendor toolchains (TwinCAT, Studio 5000, TIA Portal), each with its own build/packaging/certification quirks, so the native SDK is deferred until a design partner anchors one toolchain. Until then, integrate against the HTTP API directly — the contract is the Authority API reference.

Do not make HTTP calls from your real-time task. A SIL-rated controller's hot path has no business waiting on a cloud round-trip. Run a non-RT mediator process on the same industrial PC, route requests to it over local IPC (Unix socket / shared memory / loopback TCP), and let the mediator own TLS, retries, and idempotency.

┌─────────────────────────────┐    ┌──────────────────────────────┐
│  RT task (TwinCAT, Studio)  │    │  Non-RT mediator (this guide)│
│                             │    │                              │
│  1. fill request struct     │───▶│  2. POST /v1/physical/…       │──▶ Intended Cloud
│  6. read decision struct    │◀───│  3. parse response            │
│  7. dispatch motion / SD    │    │  4. publish to local IPC      │
└─────────────────────────────┘    └──────────────────────────────┘

When the edge verifier binary ships (roadmap), it takes over local verification at step 6 and runs sub-50ms. The mediator continues to handle issuance.

Register the actor first#

Issuance for an unregistered actorIdentity returns 403 actor_not_registered. Register each robot once at commissioning:

bash
curl -X POST "$INTENDED_API_URL/v1/physical/actors" \
  -H "Authorization: Bearer $INTENDED_API_KEY" \
  -H "x-tenant-id: $INTENDED_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{"actorIdentity": "axis-station-7", "actorKind": "robot"}'

Minimum viable mediator#

Any reasonable HTTP/JSON stack works. Below: libcurl + nlohmann/json.

cpp
#include <curl/curl.h>
#include <nlohmann/json.hpp>
#include <string>
#include <chrono>

using nlohmann::json;

namespace intended {

struct StructuredGoal {
    std::string schema;            // e.g. "opc-ua:1.04:method"
    std::string verb;              // e.g. "move-axis"
    std::string object;            // optional target
    json        parameters;
    std::string actor_kind;        // "robot" | "cobot" | "agv" | …
    std::string actor_identifier;  // must be registered
};

struct PhysicalDagNode {
    std::string node_id;
    std::string oil_code;
    int         deadline_ms;       // == the token's lifetime
    std::string safe_default;      // "stop" | "hold-position" | …
    std::string real_time_tier;    // "best-effort" | "rt-soft" | "rt-hard"
};

// Mirrors the ALLOW response body of POST /v1/physical/authority-tokens.
struct AuthorityResult {
    std::string decision;          // "ALLOW" | "ESCALATE" | "DENY"
    std::string token;             // present on ALLOW
    int64_t     expires_at_ms = 0; // present on ALLOW (issuedAt + deadlineMs)
    std::string oil_code;
    bool        safety_bit = false;
    std::vector<std::string> safety_citations;
    std::string safe_default;      // present on DENY / ESCALATE
    std::string escalation_ticket_id; // present on ESCALATE
};

class Client {
public:
    Client(std::string base_url, std::string api_key, std::string tenant_id)
        : base_url_(std::move(base_url)), api_key_(std::move(api_key)),
          tenant_id_(std::move(tenant_id)) {}

    AuthorityResult classify_and_issue(
        const StructuredGoal& goal, const PhysicalDagNode& dag,
        const json& physical_state, const std::string& idempotency_key)
    {
        json payload = {
            {"intent", {{"structuredGoal", to_payload(goal)}}},
            {"dagNode", to_payload(dag)},
            {"claims", {
                {"actorIdentity", goal.actor_identifier},
                {"deadlineMs",    dag.deadline_ms},
                {"safeDefault",   dag.safe_default},
                {"safetyBit",     true},
            }},
            {"physicalState", physical_state},
        };
        return post("/v1/physical/authority-tokens", payload, idempotency_key);
    }

private:
    AuthorityResult post(const std::string& path, const json& body,
                         const std::string& idempotency_key);
    // … see the libcurl appendix; sets Authorization, x-tenant-id,
    //   Idempotency-Key, Content-Type, and CURLOPT_TIMEOUT_MS.

    std::string base_url_, api_key_, tenant_id_;
};

}  // namespace intended

Decision values

The physical issuer returns ALLOW / ESCALATE / DENY. ALLOW and ESCALATE arrive as HTTP 200; DENY arrives as HTTP 422 (or 423 for an audit-gap clause) with the deciding clause and safeDefault under error.details. Branch on the HTTP status, then on decision.

Calling from a TwinCAT / PLC RT task#

Don't link libcurl into your RT task. Expose the mediator over ADS / shared memory and let the PLC poll a typed structure:

structured-text
(* TwinCAT 3 ST — request issuance *)
TYPE ST_IntentRequest :
STRUCT
    schema      : STRING(80);
    verb        : STRING(40);
    object      : STRING(80);
    deadline_ms : UDINT;
    request_id  : UDINT;
END_STRUCT
END_TYPE

TYPE ST_AuthorityResponse :
STRUCT
    request_id    : UDINT;
    decision      : INT;        (* 0=ALLOW 1=ESCALATE 2=DENY *)
    token_handle  : UDINT;       (* mediator-side handle into a token cache *)
    expires_at_ms : LINT;
    safety_bit    : BOOL;
    safe_default  : STRING(40);
END_STRUCT
END_TYPE

The mediator owns the token blob (an RS256 JWT, ~700 bytes). The PLC holds a handle so it can hand the token to a downstream verifier. When the edge verifier ships, token_handle becomes the input to a local verify call.

Surfacing safety-bus state#

The cloud needs structured predicate values, not raw register reads. Bridge once in the mediator and pass the snapshot to each issuance:

cpp
json snapshot;
snapshot["safety/emergency_stop"] = {
    {"kind", "boolean"},
    {"value", read_estop_chain()},   // ADS read or PROFIsafe register
    {"asOfTimestampMs", now_ms()},
    {"attestation", {
        {"channel", "twincat-safety-plc/estop-chain"},
        {"safetyRated", true},
        {"protocol", "profisafe"},
    }},
};

Mark a predicate safetyRated: true only when it actually traverses a safety-rated channel. Misclaiming attestation is a control failure an auditor will catch — and it is the clause that gates "is this robot allowed to move at all."

What you give up without a native SDK#

  • No offline classifier fallback. The mediator must fail closed (actuate the safe-default) if the cloud is unreachable.
  • No type-checked schema. You author the JSON by hand; the schema lives in packages/contracts/src/physical-ai.ts — treat it as the spec.
  • No local verification. Each issuance is a round trip. For high-rate cycles, pre-issue and cache against expiresAtMs.

Appendix — libcurl POST helper#

cpp
namespace {

size_t write_cb(char* ptr, size_t size, size_t nmemb, void* userdata) {
    static_cast<std::string*>(userdata)->append(ptr, size * nmemb);
    return size * nmemb;
}

std::string http_post_json(
    const std::string& url, const std::string& body,
    const std::string& bearer, const std::string& tenant_id, long timeout_ms,
    const std::string& idempotency_key = "")
{
    CURL* curl = curl_easy_init();
    if (!curl) throw std::runtime_error("curl_easy_init failed");

    curl_slist* hdrs = nullptr;
    hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
    std::string auth   = "Authorization: Bearer " + bearer;
    std::string tenant = "x-tenant-id: " + tenant_id;
    hdrs = curl_slist_append(hdrs, auth.c_str());
    hdrs = curl_slist_append(hdrs, tenant.c_str());
    if (!idempotency_key.empty()) {
        std::string idem = "Idempotency-Key: " + idempotency_key;
        hdrs = curl_slist_append(hdrs, idem.c_str());
    }

    std::string out;
    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
    curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
    curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out);
    curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms);

    CURLcode rc = curl_easy_perform(curl);
    long http_code = 0;
    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
    curl_slist_free_all(hdrs);
    curl_easy_cleanup(curl);

    if (rc != CURLE_OK)
        throw std::runtime_error(std::string("curl: ") + curl_easy_strerror(rc));
    // 422/423 are policy denials, not transport errors — return the body and
    // branch on it in the caller rather than throwing.
    if (http_code >= 400 && http_code != 422 && http_code != 423)
        throw std::runtime_error("http " + std::to_string(http_code) + ": " + out);
    return out;
}

int64_t now_ms() {
    using namespace std::chrono;
    return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
}

}  // namespace

See also#

C++ Industrial Controls | Intended