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.

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.

C++ integrations call the physical-AI cloud HTTP API directly from a small mediator process you write. This guide shows that pattern; the wire 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      │
└─────────────────────────────┘    └──────────────────────────────┘

The mediator owns issuance. Downstream components that need to check a token before actuating verify its RS256 signature against the cloud-served JWKS at /.well-known/jwks.json — see Verify Decision Tokens.

Register the actor first#

Issuance for an unregistered actorIdentity returns 403 actor_not_registered. Register each robot once at commissioning. Registering actors and minting tokens both require an API key (or user) holding the admin:config:write permission; otherwise the API returns 403 forbidden.

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 <chrono>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <vector>

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 ("" = omit)
    json        parameters = json::object();
    std::string actor_kind;        // "robot" | "cobot" | "agv" | …
    std::string actor_identifier;  // must be registered
};

struct PhysicalDagNode {
    std::string node_id;
    std::string oi_code;           // "OI-" + 3-4 digits
    int         deadline_ms;       // == the token's lifetime (max 300000)
    std::string safe_default;      // "stop" | "hold-position" | …
    std::string real_time_tier;    // "best-effort" | "rt-soft" | "rt-hard"
};

// Fields of the 200 response body of POST /v1/physical/authority-tokens.
struct AuthorityResult {
    std::string decision;          // "ALLOW" | "ESCALATE" (DENY arrives as 422/423)
    std::string token;             // ALLOW only
    std::string token_jti;         // ALLOW only
    int64_t     expires_at_ms = 0; // ALLOW: issuedAt + deadlineMs; ESCALATE: ticket expiry
    std::string oi_code;           // ALLOW only
    bool        safety_bit = false;
    std::vector<std::string> safety_citations;
    std::string safe_default;      // ESCALATE only
    std::string escalation_ticket_id; // ESCALATE only
};

inline json to_payload(const StructuredGoal& g) {
    json j = {
        {"schema", g.schema},
        {"verb", g.verb},
        {"parameters", g.parameters},
        {"actor", {{"kind", g.actor_kind}, {"identifier", g.actor_identifier}}},
    };
    if (!g.object.empty()) j["object"] = g.object;
    return j;
}

inline json to_payload(const PhysicalDagNode& n) {
    return {
        {"nodeId", n.node_id},
        {"oiCode", n.oi_code},
        {"deadlineMs", n.deadline_ms},
        {"safeDefault", n.safe_default},
        {"realTimeTier", n.real_time_tier},
    };
}

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& mission_id,
        const std::string& idempotency_key)
    {
        json payload = {
            {"intent", {{"structuredGoal", to_payload(goal)}}},
            {"dagNode", to_payload(dag)},
            {"missionId", mission_id},
            {"claims", {
                {"actorIdentity", goal.actor_identifier},
                {"deadlineMs",    dag.deadline_ms},
                {"safeDefault",   dag.safe_default},
                {"safetyBit",     true},
            }},
        };
        // physicalState is optional; omit it rather than sending null.
        if (physical_state.is_object()) payload["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; a policy DENY arrives as HTTP 422 (policy_denied, or 423 for an audit-gap clause) with decision, clauseId, and safeDefault under error.details. Other non-200 responses (400 validation, 402 spend_cap_reached, 403 actor_not_registered / forbidden, 409 idempotency conflicts) mean no token was minted. Branch on the HTTP status, then on decision, and actuate the safe-default on anything other than ALLOW.

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, which checks it against the cloud-served JWKS before motion is commanded.

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."

Mediator responsibilities#

  • Fail closed. Classification and issuance happen in the cloud. If the cloud is unreachable or returns anything other than an ALLOW, the mediator reports the safe-default to the PLC.
  • Author the JSON to the schema. The request and response shapes are defined in packages/contracts/src/physical-ai.ts and documented in the Authority API reference — treat them as the spec.
  • Budget for the round trip. Each issuance is an HTTP call. For high-rate cycles, pre-issue and cache against expiresAtMs, and send an Idempotency-Key so retries replay the original response instead of minting twice.

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