Concepts¶
Heddle SDKs are not Python bindings. They are typed views over Heddle's wire protocol.
The useful boundary is:
Heddle runtime Heddle SDK worker
---------------- ----------------------------
Router Decodes TaskMessage JSON
Orchestrator Validates payload shape
NATS bus <----> Processes a native payload
Python workers Encodes TaskResult JSON
Workshop Publishes to result subject
The envelope and the payload¶
Every task has two layers:
| Layer | Meaning |
|---|---|
| Envelope | Routing, lifecycle, metadata, trace context. Stable across worker types. |
| Payload | Worker-specific JSON object. Validated by the worker's input schema. |
TaskMessage is the request envelope. TaskResult is the response envelope.
Both use snake_case wire keys, even when the language API uses PascalCase or
camelCase names.
Subjects¶
| Subject | Purpose |
|---|---|
heddle.tasks.incoming |
Client or orchestrator publishes tasks for routing. |
heddle.tasks.{worker_type}.{tier} |
Router dispatches tasks to worker replicas. |
heddle.results.{parent_task_id} |
Worker publishes a result for an orchestrator goal. |
heddle.results.default |
Worker publishes a standalone result. |
heddle.tasks.dead_letter |
Router publishes unroutable or rate-limited tasks. |
heddle.control.reload |
Optional hot-reload broadcast. |
Foreign processor workers should subscribe to
heddle.tasks.{worker_type}.{tier} with queue group
processors-{worker_type}. Queue groups prevent every replica from receiving
the same task.
Transports¶
SDK workers depend on a small publish/subscribe transport boundary, not on a
specific broker client. The checked-in .NET and Swift packages include
process-local in-memory transports for tests and examples. Those transports
match Heddle's local InMemoryBus queue-group behavior, but they do not cross
process boundaries.
Use a shared broker transport, usually NATS, when a native worker needs to participate in a live Heddle or Workshop runtime.
Worker lifecycle¶
The SDK worker base follows the upstream Heddle lifecycle:
- Receive bytes from a transport.
- Decode a
TaskMessage. - Skip malformed input and keep the loop alive.
- Run shallow input validation.
- Decode the worker payload into a native type.
- Process the payload.
- Encode output and verify it is a JSON object.
- Run shallow output validation.
- Publish a
TaskResult. - Reset before the next task.
Writing a worker — the SDK author's API¶
The boundary between "what the SDK does for you" and "what you implement" is intentionally tight. You implement one method; the base class handles everything else.
What you implement¶
// .NET
protected override Task<WorkerOutput<MyOutput>> ProcessAsync(
MyPayload payload,
JsonObject metadata,
CancellationToken cancellationToken)
// Swift
override func process(
payload: MyPayload,
metadata: [String: JSONValue]
) async throws -> WorkerOutput<MyOutput>
You receive a typed payload (already deserialised from the wire,
already shallow-validated against your InputSchema / inputSchema
if you provided one) and the inbound task's metadata dictionary. You
return a WorkerOutput<MyOutput> containing your typed domain output
plus optional metrics (ModelUsed / modelUsed, TokenUsage /
tokenUsage, Metadata / metadata).
Wire envelope vs. SDK return type¶
This split is the most important thing to internalise:
| Wire envelope (on NATS) | SDK return type (in code) | |
|---|---|---|
| .NET | TaskResult |
WorkerOutput<TOutput> |
| Swift | TaskResult |
WorkerOutput<Output> |
| Constructed by | the base class | your ProcessAsync / process |
| Contains | routing fields, status, timing, trace context, output, metrics | typed output + optional metrics |
| Serialised to the bus? | yes | no, never |
WorkerOutput is the SDK's ergonomic shape for "what a worker
produces." The base class transforms it into TaskResult for the
wire — filling in task_id, parent_task_id, worker_type,
status, processing_time_ms, _trace_context, and your typed
output as the output field. If you ever see WorkerOutput on the
wire, that's a bug.
What the base class handles for you¶
The base class (HeddleWorker<TPayload, TOutput> in .NET,
HeddleWorker<Payload, Output> in Swift) owns:
- Subscription: subscribes to
heddle.tasks.{worker_type}.{tier}with queue groupprocessors-{worker_type}. Queue groups give you free horizontal scaling — run N replicas, each gets ~1/N of the tasks. - Malformed-message resilience: bad inbound bytes call a hook
(
OnMalformedMessageAsync/malformedMessage(_:)) and the subscription loop keeps running. A single bad message must not take down a worker replica. - Shallow input/output validation: against the schemas you pass
to the constructor. "Shallow" means top-level required fields and
type checks only — matches Heddle's runtime behaviour. Deeper
domain validation belongs inside your
ProcessAsync/process. - Timing: measures elapsed processing time and emits it as
processing_time_mson the wire envelope. - Trace context propagation: copies
_trace_contextfrom the inboundTaskMessageto the outboundTaskResult. Tracing middleware injects/extracts this field — seeheddle-workspace/anchors/CONTRACT_MAP.md"Reserved middleware lane." - Failure handling: exceptions or thrown errors during your
ProcessAsync/processare converted toTaskResultwithstatus = failedand the error message. Don't catch exceptions just to swallow them — return-with-error is what the wire contract expects. - Reset: calls
ResetAsync/reset()unconditionally between tasks. Workers are stateless in every language SDK (cross-repo invariant C3); the base class enforces this regardless of your subclass discipline.
What you do NOT do¶
- Construct
TaskMessageorTaskResultdirectly. (Both can be built for tests and tooling, but in worker code you never touch them — the base class hands you a payload and takes back aWorkerOutput.) - Manage transport subscription lifecycles.
- Emit trace spans manually — that's the OTel layer's job.
- Persist state between tasks.
Example¶
The end-to-end shape, with the worker doing the minimum:
// .NET — see examples/dotnet/EchoWorker/Program.cs
public sealed class EchoWorker : HeddleWorker<EchoPayload, EchoOutput>
{
public EchoWorker() : base("echo", tier: "local") {}
protected override Task<WorkerOutput<EchoOutput>> ProcessAsync(
EchoPayload payload,
JsonObject metadata,
CancellationToken cancellationToken)
{
var output = new EchoOutput { Echo = payload.Text };
return Task.FromResult(new WorkerOutput<EchoOutput>(output));
}
}
// Swift — see examples/swift/echo-worker/Sources/EchoWorker/main.swift
final class EchoWorker: HeddleWorker<EchoPayload, EchoOutput> {
init() { super.init(workerType: "echo", tier: "local") }
override func process(
payload: EchoPayload,
metadata: [String: JSONValue]
) async throws -> WorkerOutput<EchoOutput> {
WorkerOutput(output: EchoOutput(text: payload.text.uppercased()))
}
}
That's the full surface. Domain logic goes inside ProcessAsync /
process; everything else is handled.
Shallow schema validation¶
Heddle intentionally validates only the contract boundary:
- required top-level fields
- top-level JSON type checks
It does not implement full JSON Schema in the SDK core. That matches the Python
runtime and keeps foreign workers predictable. A worker may add stricter
domain validation inside process, but that stricter behavior should be local
to the worker.
Trace context¶
Trace context rides as top-level _trace_context. SDKs preserve it on
TaskResult. A transport adapter or worker can integrate with OpenTelemetry,
but preserving the field verbatim is the minimum compatibility requirement.