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 .
Recommended architecture# 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:
cURL C++
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"}'
Any reasonable HTTP/JSON stack works. Below: libcurl + nlohmann/json.
C++
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#