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.
Recommended architecture#
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.
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#