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.

Integrating Intended in C++ Industrial Controls (DOC-P2)#

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

Status of the C++ SDK: not yet shipped. This guide describes the canonical HTTP shape of the Intended cloud API plus the recommended integration pattern. Once the C++ SDK lands (post-Phase 1), it will wrap exactly the calls described here.

Why the SDK lags Python / ROS2#

Industrial-controls C++ is fragmented across vendor toolchains — TwinCAT, Studio 5000, TIA Portal each have their own build, packaging, and certification quirks. We're building the SDK after we have a design partner anchored in one toolchain so we ship a real artifact and not a least-common-denominator one. Until then, this is the path that works today.

Don't make HTTP calls from your real-time task. The hot path on a SIL-2 controller has no business waiting on cloud round-trips. Run a non-RT mediator process on the same industrial PC, route requests to it over a local IPC channel (Unix socket / shared memory / loopback TCP), and let the mediator handle TLS + retries.

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

When the Rust edge verifier ships (TOK-P1), the verifier binary takes over step 6 and runs sub-50ms locally. The mediator continues to handle issuance.

Minimum viable mediator#

Use any reasonable HTTP/JSON stack. Below uses libcurl + nlohmann/json for portability; cpprestsdk, Boost.Beast, Poco, or Crow work equally well.

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. "opcua:method:siemens.tia/MoveAxis"
    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;  // your DevID
};

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

struct AuthorityToken {
    std::string token;
    int64_t expires_at_ms;
    std::string oil_code;
    bool safety_bit;
    std::vector<std::string> safety_citations;
};

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

    AuthorityToken classify_and_issue(
        const StructuredGoal& goal,
        const PhysicalDagNode& dag,
        const json& physical_state)
    {
        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);
    }

private:
    AuthorityToken post(const std::string& path, const json& body) {
        // libcurl request — see the appendix at the end of this guide
        // for a full, copy-pasteable implementation. Sets:
        //   Authorization: Bearer <api_key>
        //   Content-Type:  application/json
        //   Timeout:       deadline_ms (use CURLOPT_TIMEOUT_MS)
        ...
    }

    std::string base_url_;
    std::string api_key_;
};

}  // namespace intended

Calling from a TwinCAT / PLC RT task#

Don't link libcurl into your RT task. Instead 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=DENY 2=ESCALATE *)
    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 (it's an RS256 JWT, ~700 bytes). The PLC only needs the handle so it can hand the token to a downstream verifier. Once the Rust edge verifier ships, token_handle becomes the input to intended_verifier_check(handle, &decision_out).

Surfacing safety-bus state#

The cloud needs structured predicate values, not raw register reads. Bridge your safety bus 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 predicates as safetyRated: true only when they actually traverse a safety-rated channel. Misclaiming attestation is a control failure auditors will catch — and it's the policy clause that gates "is this robot allowed to move at all."

What you lose without the C++ SDK#

  • No offline classifier fallback. The Python SDK has a rule-based classifier for offline / edge-disconnected use. Your C++ mediator has to fail closed if the cloud is unreachable.
  • No streaming / async batch issuance. Each token is a single round trip. For high-rate operating cycles, pre-issue and cache.
  • No type-checked schema. You author the JSON shape by hand. The schema lives in packages/contracts/src/physical-ai.ts treat it as the spec until the C++ SDK packages it.

When the C++ SDK ships#

We'll publish:

  • A header-only intended/physical.hpp with StructuredGoal, PhysicalStateValue, PhysicalDagNode, AuthorityToken as POD types.
  • A libintended.{a,so} wrapping libcurl + zstd + the edge verifier.
  • A TwinCAT 3 wrapper library (PLM-friendly function blocks).
  • A Studio 5000 add-on instruction.
  • A TIA Portal SCL binding.

Ship target: tied to first design-partner deal in industrial controls (see roadmap §SI / GTM-P3).

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, long timeout_ms)
{
    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;
    hdrs = curl_slist_append(hdrs, auth.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));
    if (http_code >= 400)
        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