helixordevelopers

Serving decisions

When the caller is not Python, run the runtime as a small decision service next to it. The service evaluates in-process exactly as the library does; HTTP is only the transport.

About the example

This page 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.

Start the service#

helixor-pack serve --pack acme.project_codes.v1.hxpack \
                   --license ~/.helixor/helixor.lic \
                   --host 127.0.0.1 --port 18734
from helixor_runtime import HelixorEngine
from helixor_runtime.server import start_server, stop_server

engine = HelixorEngine.load_pack("acme.project_codes.v1.hxpack",
                                 license_file="~/.helixor/helixor.lic")
server, thread, base_url = start_server(engine, host="127.0.0.1", port=0)
print(base_url)          # e.g. http://127.0.0.1:53817
...
stop_server(server, thread)
python -m helixor_runtime.server --pack acme.project_codes.v1.hxpack \
                                 --license ~/.helixor/helixor.lic \
                                 --host 127.0.0.1 --port 18734

Every form binds 127.0.0.1 by default and serves the engine or pack you give it. start_server() with no engine, and the module without --pack, serve the built-in example pack. Check GET /v1/health: it reports the pack_id and the license tier (engine_edition) actually being served.

Endpoints#

EndpointPurpose
GET/v1/healthService metadata and pack ID.
POST/v1/evaluateEvaluate one payload; returns the full result.
POST/v1/evaluate/streamEvaluate a text as a simulated stream; Server-Sent Events.
POST/v1/decisionGeneric "decision as a function" envelope: {"domain": "evaluate", "parameters": {"text": ...}}.
WS/v1/ws/decisionInteractive evaluation and true incremental streaming.

Full request and response schemas are in the HTTP API reference.

Call it#

curl -s http://127.0.0.1:18734/v1/evaluate \
  -H 'Content-Type: application/json' \
  -d '{"text": "Reach me at dev@example.com"}'
const res = await fetch("http://127.0.0.1:18734/v1/evaluate", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ text: "Reach me at dev@example.com" }),
});
if (!res.ok) throw new Error(`decision service ${res.status}`);
const r = await res.json();
const fatal = r.triggers.some((t: { severity: string }) => t.severity === "FATAL");
const forward = fatal || r.action.startsWith("block_") ? null : r.remedy.clean_text;
var body = "{\"text\": \"Reach me at dev@example.com\"}";
var req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:18734/v1/evaluate"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();
// HTTP/1.1 is required: with the default HTTP/2 upgrade the service sees an empty body (422)
var client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new IllegalStateException("decision service " + res.statusCode());

Treat any non-200 response, timeout or connection failure as a block. The service being unavailable must never mean "allow".

Streaming over WebSocket#

Send stream_start, then one stream_chunk per fragment as it arrives, then stream_flush. The service answers each push with zero or more stream_token messages, exactly like a local session.

{"type": "stream_start", "lookahead_chars": 28, "block_on_fatal": true}
{"type": "stream_chunk", "token": "Identified SSN is 123"}
{"type": "stream_chunk", "token": "-45-6789."}
{"type": "stream_flush"}

The SSE endpoint takes the whole text in one request and splits it into chunk_size pieces itself. It is useful for demonstrations and tests; use WebSocket for real incremental output.

Security#

  • Bearer token. Give the service a token with --token, start_server(..., token=...) or the HELIXOR_SERVICE_TOKEN environment variable. Every endpoint except GET /v1/health then requires Authorization: Bearer <token> and answers 401 without it; a WebSocket without it is closed with code 1008. Without a token there is no authentication, so any process that can reach the port can call it.
  • Loopback by default. The service binds 127.0.0.1 unless you pass another host, and it refuses to start on a non-loopback address without a token (ServerConfigurationError).
  • No cross-origin access by default. Browser origins are allowed only when you list them in HELIXOR_CORS_ORIGINS (comma-separated). A wildcard origin is rejected at startup.
  • One pack per service. A service that serves a compiled pack rejects a request that carries its own license_key with 400, so a caller cannot swap in a different engine.
  • Responses contain the matched values. /v1/evaluate returns triggers[].matched_items; with the example pack, those are the sensitive values themselves. Do not log response bodies in a proxy or gateway.
  • Deny egress. The service needs no outbound network access to evaluate. Enforce that with a network policy.

A token authenticates callers but does not encrypt the connection. For anything beyond loopback, terminate TLS in front of the service. Deployment patterns with manifests are in Deployment patterns.

Known issue in 0.2.1#

WebSocket streams fail open after a block

After a stream_token with blocked: true, the next stream_chunk on the same connection silently starts a new, unblocked session, and text after the fatal value is streamed back. Close the connection as soon as you receive a blocked token.

Fixed in the next release. After a blocked token, every later stream_chunk on the connection is refused with {"type": "error", "code": "STREAM_BLOCKED"} and nothing more is released, until the client sends stream_start. Closing the connection on a block remains good practice.