Foreign-Language Actors — The Wire Protocol¶
Audience: authors of Heddle actors written in languages other than Python (.NET, Swift, Go, Rust, etc.).
Heddle's framework is Python, but the wire protocol between actors is language-agnostic: NATS subjects + JSON messages. This document specifies the contract a non-Python actor must implement to participate in a Heddle bus.
Reference SDKs
The companion getheddle/heddle-sdk repository packages this protocol for .NET and Swift. The rendered SDK docs live at https://getheddle.dev/heddle-sdk/.
The envelope¶
Every message on the bus is a JSON object that carries two distinct things:
- Envelope fields — routing, observability, lifecycle metadata. Stable across worker types. The router and orchestrator care about these.
- Payload — domain-specific data, shaped by the worker's
input_schema/output_schema. Opaque to the framework.
TaskMessage is the request envelope. TaskResult is the response
envelope. Both are Pydantic models in heddle.core.messages (see
src/heddle/core/messages.py in the repository); the canonical JSON
Schemas are exported to schemas/v1/ on every commit and gated by a
CI drift check — they are the authoritative wire contract.
The optional _trace_context field is a top-level envelope extension used by
Heddle tracing helpers to carry W3C propagation headers. Treat it as framework
metadata and pass it through separately from worker metadata and payload.
TaskMessage (request)¶
heddle.tasks.incoming
▲
│ (router subscribes; dispatches based on worker_type + tier)
│
heddle.tasks.{worker_type}.{tier}
▲
│ (processor workers subscribe with queue group processors-{worker_type})
│
└── TaskMessage ─── JSON object ─────────────────────┐
│
task_id str (UUID) │
parent_task_id str | null (goal_id when from orch) │
worker_type str │ ENVELOPE
model_tier "local" | "standard" | "frontier" │
priority "low" | "normal" | "high" | "critical"│
created_at str (ISO 8601 UTC) │
request_id str | null │
metadata object │
_trace_context object | null (W3C headers) │
───────────────────────────────────────────────────────── ┘
payload object ← validated against ╮
worker.input_schema │ PAYLOAD
╯
TaskResult (response)¶
heddle.results.{parent_task_id or "default"}
▲
│ (orchestrator subscribed before publishing TaskMessage)
│
└── TaskResult ──── JSON object ─────────────────────┐
│
task_id str (matches TaskMessage.task_id) │
parent_task_id str | null (matches TaskMessage) │
worker_type str │ ENVELOPE
status "completed" | "failed" │
| "pending" | "processing" │
error str | null │
model_used str | null │
token_usage object ({"prompt_tokens": int, ...}) │
metadata object │
processing_time_ms int │
completed_at str (ISO 8601 UTC) │
_trace_context object | null (W3C headers) │
───────────────────────────────────────────────────────── ┘
output object | null ← validated against ╮
worker.output_schema │ PAYLOAD
╯
Generating typed wrappers in your language¶
If you are targeting .NET or Swift, start with the reference SDKs: .NET and Swift. The schema-generation flow below is for SDK maintainers and for languages that do not yet have a packaged SDK.
The Pydantic-exported JSON Schemas in schemas/v1/ plug straight into
off-the-shelf code generators. No custom IDL or designer tool — the
wire contract IS the schema.
Swift (idiomatic Codable types):
quicktype \
--src schemas/v1/task_message.schema.json \
--src-lang schema \
--lang swift \
--top-level TaskMessage \
-o Sources/HeddleActor/TaskMessage.swift
.NET / C# (with System.Text.Json attributes):
quicktype \
--src schemas/v1/task_message.schema.json \
--src-lang schema \
--lang csharp \
--framework SystemTextJson \
--top-level TaskMessage \
-o src/Heddle.Actor/TaskMessage.cs
Other languages — quicktype supports TypeScript, Kotlin, Go,
Rust, Java, Ruby, Python (dataclasses), and more. Use the same
invocation, varying --lang. For Python projects that want their own
typed view, datamodel-code-generator produces Pydantic models from
the same JSON Schemas.
Wire-format compatibility is guaranteed across language SDKs: every
SDK generates types from the same schemas/v1/*.schema.json files,
which are themselves generated from the canonical Pydantic models on
every commit (CI fails on drift).
NATS subjects¶
| Subject | Direction | Notes |
|---|---|---|
heddle.tasks.incoming |
client → router | Where orchestrators / clients publish TaskMessages for routing. Foreign actors rarely subscribe here directly. |
heddle.tasks.{worker_type}.{tier} |
router → worker | Processor workers subscribe with queue group name = processors-{worker_type} so multiple replicas of the same worker load-balance via NATS queue-group semantics. |
heddle.tasks.dead_letter |
router → operator | Unroutable or rate-limited messages land here. Foreign actors don't publish here; the router does. |
heddle.results.{parent_task_id} |
worker → orchestrator | Each goal opens a unique result subject; the worker echoes back to it. The orchestrator subscribes BEFORE publishing the task (Invariant 17). |
heddle.results.default |
worker → standalone caller | Used when a TaskMessage has no parent_task_id. |
heddle.control.reload |
operator → all actors | Optional config hot-reload broadcast. Foreign actors MAY subscribe to re-read their config. |
Required behaviours¶
A foreign actor MUST observe these to interoperate cleanly with the Python framework:
- Queue-group subscription. Subscribe to
heddle.tasks.{worker_type}.{tier}withqueue_group=processors-{worker_type}. Without the queue group, every replica receives every task. - Reset between tasks (Invariant 1). No state may carry from one
task to the next inside one actor process. The Python framework
enforces this via a
reset()call in afinallyblock; foreign actors must achieve the same effect (clear per-task locals before the nextrecv()call). - Skip-and-log on malformed input (Invariant 8). If the bytes
don't parse as JSON, or don't conform to
TaskMessage, log a warning and continue the subscription loop. Do not crash the process; do not retry the bad message — the publisher needs to fix its output. - Subscribe-before-publish for the result subject is the
orchestrator's responsibility, not the worker's. Workers just
publish to
heddle.results.{parent_task_id}when done. - JSON Schema validation is intentionally shallow (Invariant 5):
required-field presence + top-level type checks. Foreign SDKs
SHOULD implement the same shallow validation against
input_schema/output_schemafrom the worker's config. Implementing full JSON Schema validation is allowed but produces stricter behaviour than the Python actors; document the divergence if you do. - Middleware Lane propagation. If the incoming message has
top-level keys starting with
_(e.g._trace_context), extract any you understand (like W3C Trace Context) and always include every_-prefixed key verbatim on theTaskResult. Do not put these undermetadata; they belong at the top level of the JSON envelope.
Scope — what foreign actors are (and aren't)¶
Foreign actors are positioned as processor workers: they run
compute, ML inference, transformations, native-library calls, etc.
The framework supports them as first-class participants on the bus.
What they are not (today, by design):
- LLM workers. Heddle's LLM backends (Anthropic, OpenAI, Ollama, LM Studio) are Python-native and offer features (retry policy, reasoning-content rescue, thinking-block surface) that haven't been replicated in other languages. A foreign actor that needs LLM inference should call a sibling Python LLM worker via the bus.
- Knowledge-silo consumers.
docproc,rag,lancedb, etc. are Python-side abstractions over file formats and embedding stores. Foreign actors that need these capabilities call sibling Python workers; they don't reimplement silo access. - Tool providers. Same shape as silos.
This scope keeps the foreign-actor wire surface small and well-defined. If you find yourself wanting to reimplement an LLM backend or a silo adapter in .NET / Swift, the cleaner shape is a Python proxy worker that exposes the capability over the bus.
Configuration¶
A foreign actor's worker config YAML lives alongside Python worker
configs in configs/workers/<name>.yaml. The fields the router and
orchestrator care about are unchanged:
name: image_classifier # worker_type
worker_kind: processor # foreign actors are always 'processor'
default_model_tier: local
input_schema:
type: object
required: [image_url]
properties:
image_url: {type: string}
output_schema:
type: object
required: [label, confidence]
properties:
label: {type: string}
confidence: {type: number}
implementation: # OPTIONAL — interpreted by Workshop
runtime: dotnet # tooling so it can offer the
entry: ./bin/Heddle.ImageClassifier # right lifecycle affordances
# (test-run, eval-runs, etc.)
# without trying to import a
# Python module.
The implementation block is informational — the framework doesn't
launch foreign workers itself. Run them under systemd, Docker,
launchd, or whatever your deploy target supports; they connect to
NATS the same way Python workers do.
Reference SDKs¶
The companion getheddle/heddle-sdk repository is the reference implementation of this protocol outside Python. It currently includes:
- .NET SDK —
Heddle.Sdkclient and actor runtime for processor workers. - Swift SDK —
HeddleActorclient and actor runtime for Swift services. - SDK examples — minimal processor actors and callers that match this wire contract.
For other languages, the wire spec above plus schemas/v1/ is sufficient to
build a small SDK. The protocol surface is intentionally narrow.
See also¶
schemas/v1/(in the repository) — authoritative JSON Schemas, CI-checked.- Heddle SDK docs — .NET and Swift reference SDK documentation.
- getheddle/heddle-sdk — SDK source repository.
- Gateway Actors — for non-NATS protocols (MQTT, HTTP, IoT).
- Design Invariants — framework-safety contracts (1, 5, 8, 17 in particular apply to foreign actors).
- ADR-001 — why the reset-between-tasks invariant exists. etheddle/heddle-sdk) — SDK source repository.
- Gateway Actors — for non-NATS protocols (MQTT, HTTP, IoT).
- Design Invariants — framework-safety contracts (1, 5, 8, 17 in particular apply to foreign actors).
- ADR-001 — why the reset-between-tasks invariant exists.