Serve decisions to other languages
You run the runtime as a small HTTP and WebSocket service on the same host as your application, then call it from curl, Node and Java. The decision logic stays in the Python process; the other languages only see JSON.
About the example
This tutorial uses the built-in data-protection pack so you can run everything without writing a pack first. The same calls work for any decision pack: eligibility, limits, routing and so on. See Core concepts.
What you'll build#
serve_local.py: the built-in example pack served on127.0.0.1:18734.- Clients for POST
/v1/evaluatein curl, Node (fetch) and Java (HttpClient). - A Node client for the WS
/v1/ws/decisionstreaming protocol. - The same service for a pack you compiled, with
helixor-pack serve.
Prerequisites#
- The runtime installed with its service dependencies (
fastapi,uvicorn,websockets). See Installation. - For the clients: Node 22 or later (built-in
fetchandWebSocket) and Java 17 or later. - For step 6 only: a compiled pack and your Developer license. See Add custom rules.
Who can call the service
The service binds 127.0.0.1 by default and refuses to bind any other address unless you give it a bearer token (token=, --token or HELIXOR_SERVICE_TOKEN). With a token, every endpoint except GET /v1/health answers 401 unless the request carries Authorization: Bearer <token>; the WebSocket closes with code 1008. Without a token, any process on the host can call it, so treat the port as part of your process. Browser cross-origin calls are off unless you list origins in HELIXOR_CORS_ORIGINS. Read Security before another host calls it.
Steps#
- Start the service
start_server()serves the engine you pass on a background thread and returns once/v1/healthanswers. The script then waits until you press Ctrl+C.import signal import threading from helixor_runtime import HelixorEngine from helixor_runtime.server import start_server, stop_server engine = HelixorEngine() print(f"Serving {engine.pack_id} ({engine.tier})") server, thread, base_url = start_server(engine, host="127.0.0.1", port=18734) print(f"Listening on {base_url} (Ctrl+C to stop)") stop = threading.Event() signal.signal(signal.SIGINT, lambda *_: stop.set()) signal.signal(signal.SIGTERM, lambda *_: stop.set()) stop.wait() stop_server(server, thread) print("Stopped")python serve_local.py
Serving compliance.regulatory_pii_guard.v1 (COMMUNITY) Listening on http://127.0.0.1:18734 (Ctrl+C to stop)
Leave it running and use a second terminal for the next steps.
- Call it with curl
curl -s http://127.0.0.1:18734/v1/health curl -s -X POST http://127.0.0.1:18734/v1/evaluate \ -H 'Content-Type: application/json' \ -d '{"text": "Wire the refund to jane@example.com, SSN 123-45-6789."}' \ | python3 -m json.toolThe evaluate response is the same decision you get in Python, as JSON (upgrade notice trimmed):
{ "pack_id": "compliance.regulatory_pii_guard.v1", "action": "block_glba_ssn_leakage", "invariants_passed": false, "reason": "Statutory violation: Social Security Number (GLBA / FCRA) detected", "triggers": [ { "rule_id": "RULE-GLBA-SSN-BLOCK", "law": "Gramm-Leach-Bliley Act & FCRA", "severity": "FATAL", "matched_items": [ "123-45-6789" ] }, { "rule_id": "RULE-GDPR-EMAIL-REDACT", "law": "GDPR Article 6 & CCPA", "severity": "WARNING", "matched_items": [ "jane@example.com" ] } ], "remedy": { "clean_text": "Wire the refund to [REDACTED_EMAIL], SSN [REDACTED_SSN].", "redactions_count": 2, "redacted_categories": [ "SSN (GLBA/FCRA)", "Email (GDPR/CCPA)" ] }, "latency_us": 67.58, "tokens_spent": 0, "egress_bytes": 0, "receipt_hash": "hx_proof_a00acaa9bf2efcbebfceaa2d", "license_status": "COMMUNITY_PERPETUAL", "active_key_id": "hx_lic_community_developer_ep1_0", "active_epoch": 1, "edition": "COMMUNITY", "upgrade_notice": "Free Community Edition: ..." }The response contains the raw values
triggers[].matched_itemscarries the matched values in clear text; here, the SSN. Do not log response bodies. Logaction, the rule IDs andreceipt_hash. - Call it from Node
Branch on
actionexactly as in Python: forward nothing forblock*, otherwise forwardremedy.clean_text. Fail on any non-200 status instead of assuming a permit.const BASE_URL = process.env.HELIXOR_URL ?? "http://127.0.0.1:18734"; async function evaluate(text) { const res = await fetch(`${BASE_URL}/v1/evaluate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); if (!res.ok) { throw new Error(`evaluate failed: HTTP ${res.status} ${await res.text()}`); } return res.json(); } const inputs = [ "Ship the order on Friday.", "Call me at (415) 555-0100 when it ships.", "Card on file: 4111-1111-1111-1111.", ]; for (const text of inputs) { const d = await evaluate(text); const forward = d.action.startsWith("block") ? null : d.remedy.clean_text; console.log(`${d.action} forward=${JSON.stringify(forward)} receipt=${d.receipt_hash}`); }node evaluate.mjs
permit_clean_payload forward="Ship the order on Friday." receipt=hx_proof_d5f54f8b96d1601c2e0fc064 redact_and_permit_contact_pii forward="Call me at [REDACTED_PHONE] when it ships." receipt=hx_proof_1e9a30e67c36bca39498b34f block_pci_dss_pan_leakage forward=null receipt=hx_proof_2e177340720c1f974a445f14
- Call it from Java
This uses only the JDK. Pin the client to HTTP/1.1: the JDK client otherwise offers an HTTP/2 upgrade on plain HTTP, and the service then receives an empty body and answers
422with"Field required".import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; public class EvaluateClient { private static final String BASE_URL = System.getenv().getOrDefault("HELIXOR_URL", "http://127.0.0.1:18734"); public static void main(String[] args) throws Exception { HttpClient http = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(2)) .build(); String text = args.length > 0 ? args[0] : "Call me at (415) 555-0100 when it ships."; String body = "{\"text\":" + jsonString(text) + "}"; HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/v1/evaluate")) .timeout(Duration.ofSeconds(2)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { throw new IllegalStateException("evaluate failed: HTTP " + response.statusCode() + " " + response.body()); } System.out.println(response.body()); } // Minimal JSON string encoder; use your JSON library in real code. private static String jsonString(String s) { StringBuilder sb = new StringBuilder("\""); for (char c : s.toCharArray()) { switch (c) { case '"' -> sb.append("\\\""); case '\\' -> sb.append("\\\\"); case '\n' -> sb.append("\\n"); case '\r' -> sb.append("\\r"); case '\t' -> sb.append("\\t"); default -> { if (c < 0x20) sb.append(String.format("\\u%04x", (int) c)); else sb.append(c); } } } return sb.append('"').toString(); } }java EvaluateClient.java
{"pack_id":"compliance.regulatory_pii_guard.v1","action":"redact_and_permit_contact_pii","invariants_passed":false,"reason":"Regulatory notice: Direct online/contact identifier requires in-place redaction","triggers":[{"rule_id":"...In production, parse the body with your JSON library and map it to a record; the fields are listed in the HTTP API reference.
- Stream tokens over WebSocket
The WebSocket endpoint exposes a streaming session. Send
stream_start, onestream_chunkper model token, thenstream_flush. The server answers with zero or morestream_tokenmessages per chunk and a finalstream_completed.You send Server replies {"type": "ping"}{"type": "pong", "time": …}{"type": "evaluate", "text": …}{"type": "verdict", "action", "invariants_passed", "clean_text", "latency_us", "egress_bytes", "receipt_hash"}{"type": "stream_start", "lookahead_chars"?, "block_on_fatal"?}{"type": "stream_started", "lookahead_chars"}{"type": "stream_chunk", "token": …}Zero or more {"type": "stream_token", "token", "blocked", "action", "reason"}{"type": "stream_flush"}Remaining stream_tokenmessages, then{"type": "stream_completed"}Anything else {"type": "error", "message"}const WS_URL = (process.env.HELIXOR_URL ?? "http://127.0.0.1:18734") .replace(/^http/, "ws") + "/v1/ws/decision"; const tokens = ["Your agent today is ", "Dana. Reach her at ", "(415) 555-", "0100 ", "any time before ", "five. Thanks!"]; const ws = new WebSocket(WS_URL); const send = (msg) => ws.send(JSON.stringify(msg)); let out = ""; ws.addEventListener("open", () => { send({ type: "stream_start", lookahead_chars: 28, block_on_fatal: true }); }); ws.addEventListener("message", (event) => { const msg = JSON.parse(event.data); switch (msg.type) { case "stream_started": for (const token of tokens) send({ type: "stream_chunk", token }); send({ type: "stream_flush" }); break; case "stream_token": if (msg.blocked) { console.log(`BLOCKED: ${msg.action} - ${msg.reason}`); ws.close(); return; } console.log(`token: ${JSON.stringify(msg.token)}`); out += msg.token; break; case "stream_completed": console.log(`assembled: ${JSON.stringify(out)}`); ws.close(); break; case "error": console.error(`server error: ${msg.message}`); ws.close(); break; } });node stream.mjs
token: "Your agent " token: "today is " token: "Dana. " token: "Reach her at " token: "[REDACTED_PHONE] " token: "any time before five. Thanks!" assembled: "Your agent today is Dana. Reach her at [REDACTED_PHONE] any time before five. Thanks!"
Known issue in 0.2.1
A
stream_tokenwithblocked: trueends the server's session, but the nextstream_chunkon the same connection silently starts a new one, and the text after the fatal value is streamed back unblocked. The client must stop: onblocked: true, stop sending chunks and close the connection, asstream.mjsdoes.Fixed in the next release: later chunks are refused with an
errormessage, codeSTREAM_BLOCKED, until a newstream_start. Keep closing the connection on a block.As with an in-process session, evaluate the assembled reply once more before you store it; see Decide on a stream as it arrives.
For quick tests there is also POST
/v1/evaluate/stream, which splits a completetextintochunk_sizepieces on the server and returns Server-Sent Events:curl -sN -X POST http://127.0.0.1:18734/v1/evaluate/stream \ -H 'Content-Type: application/json' \ -d '{"text": "Ship Friday and call (415) 555-0100 first.", "chunk_size": 8}'data: {"chunk_index": 1, "text": "Ship ", "is_final": false, "redacted": false, "blocked": false, "action": "stream_token", "reason": null, "triggers": []} data: {"chunk_index": 2, "text": "Friday ", "is_final": false, "redacted": false, "blocked": false, "action": "stream_token", "reason": null, "triggers": []} data: {"chunk_index": 3, "text": "and call [REDACTED_PHONE] first.", "is_final": true, "redacted": true, "blocked": false, "action": "stream_token", "reason": null, "triggers": []} - Serve your own pack
To serve a pack you compiled, use the CLI with your Developer license. It unseals the pack in memory and serves the same endpoints.
helixor-pack serve --pack internal_ids.hxpack \ --license ~/.helixor/helixor.lic \ --host 127.0.0.1 --port 18734
Unsealed pack 'custom.internal_ids.v1' in RAM. Starting server on http://127.0.0.1:18734 INFO: Started server process [6994] INFO: Waiting for application startup. INFO: Application startup complete. INFO: Uvicorn running on http://127.0.0.1:18734 (Press CTRL+C to quit)
The Node and Java clients work unchanged.
/v1/healthreports the pack and license tier the service is running, so check it before you send traffic:curl -s http://127.0.0.1:18734/v1/health
{"status":"healthy","service":"helixor-decision-demos","version":"1.1.0","engine_edition":"DEVELOPER","pack_id":"custom.internal_ids.v1","supported_protocols":["REST","SSE","WebSocket"],"supported_regulations":["GLBA","PCI-DSS","HIPAA","GDPR","CCPA"],"decision_functions":["evaluate","solve_constraints","vrp","bin_packing"]}To accept calls from other hosts, bind another address and set a token:
helixor-pack serve ... --host 0.0.0.0 --token "$HELIXOR_SERVICE_TOKEN". Clients then sendAuthorization: Bearerwith that token.
How it works#
The service is a thin transport over the same engine you call in Python. Every /v1/evaluate call is one in-process evaluation, so the decision, the actions and the receipt are identical to the embedded result; latency_us in the response covers only that evaluation, not the HTTP round trip. The WebSocket session holds one streaming session per connection, which is why chunks from one reply must go over one connection.
Run one service per host (a sidecar) and call it over loopback. That keeps the payload on the machine that produced it and removes the need for TLS between the caller and the service. Deployment shapes are compared in Deployment patterns, and every endpoint is specified in the HTTP API reference. The CLI flags are in the CLI reference.