Skip to main content

Wire protocol

The Python SDK is byte-compatible with triggers-sdk-go. Every subject, header, JSON shape, and hash is locked to the Go reference implementation and verified by golden-file tests.

NATS subjects

DirectionSubjectNotes
SDK to orchestrator (req/reply)triggers.v2.manifest.registerManifest registration, 10s timeout, fail-fast
SDK to orchestrator (req/reply)triggers.v2.manifest.fetchPull current manifest (rare)
SDK to orchestrator (pub)triggers.v2.heartbeat5s ticks
SDK to orchestrator (pub)triggers.v2.flow.events.<flow_id>CompletionEvent
Orchestrator to SDK (pub)triggers.v2.flow.broadcast.<service>Per-service flow updates
Orchestrator to SDK (pub)triggers.v2.flow.broadcast.allCross-service flow updates
SDK DLQ publishtriggers.v2.dlq.<service>.<action>Per-action DLQ
SDK DLQ publish (no action ctx)triggers.v2.dlq.<service>.<kind>Fallback
Inbound sources (JetStream)triggers-sources-<service>Stream name; overridable via TRIGGERS_NATS_SOURCE_STREAM
Durable consumer nametriggers-<service>-<safe-subject>safe-subject replaces ., >, *, , \t, / with _

The SDK subscribes to BOTH broadcast subjects with plain Subscribe (not QueueSubscribe) so every replica receives every update.

The SDK does NOT publish to triggers.v2.dlq.source.<source_id>. That subject is owned by the orchestrator.

Envelope headers

The consumer expects the canonical X-Triggers-* envelope headers across transports. Header names are read case-insensitively, but the SDK does not rely on ad hoc aliases or alternate spellings.

HeaderField
X-Triggers-Trace-Idtrace_id
X-Triggers-Span-Idspan_id
X-Triggers-Traceparenttraceparent (W3C)
X-Triggers-Flow-Run-Idflow_run_id
X-Triggers-Flow-Idflow_id
X-Triggers-Flow-Versionflow_version
X-Triggers-Idempotency-Keyidempotency_key
X-Triggers-Source-Node-Idsource_node_id
X-Triggers-Retry-Countretry_count (int as string)
X-Triggers-Emitted-Atemitted_at (RFC3339Nano)
Nats-Msg-Id<idempotency_key> (NATS/JetStream dedup only)

Nats-Msg-Id is the NATS/JetStream-specific exception to the shared X-Triggers-* envelope contract. For DLQ publishes it becomes <idempotency_key>:dlq:<timestamp_ns> so repeated DLQ writes collapse under JetStream dedup.

Time encoding

Go's default time.Time JSON marshaling produces RFC3339Nano (9-digit fractional seconds, e.g. "2026-05-26T14:30:45.123456789Z"). Python's datetime.isoformat() defaults to microsecond precision, which would break byte-equality on round-trip.

The Python SDK uses a custom encoder (triggers/wire/codec.py) that pads fractional seconds to 9 digits. Every time field (started_at, now, finished_at, occurred_at, emitted_at) goes through it.

Manifest hash

Algorithm verified against triggers-sdk-go/manifest/manifest.go (HashInput / Hash).

  • SHA256 over canonical JSON of a subset; prefix sha256:; hex-encoded.
  • JSON encoder runs json.Encoder.Encode() then strips the trailing newline. The Python implementation mirrors this with compact JSON + no trailing newline.
  • SetEscapeHTML(false) so <, >, & stay as literal UTF-8. Python uses ensure_ascii=False and does NOT escape HTML entities.
  • Hash subset: only actions, service_sources, supports_nodes, supports_rate_limit, optional runtime_capabilities, and optional runtime_lookups. Everything else is excluded.
  • Per-action exclusions before hashing (catalog fields zeroed): display_name, description, version, lifecycle, action_env, result_event, compatibility. Only name, input_schema, result_schema, timeout_ms, retry, dlq survive into the hash.
  • Sort order: actions and service_sources by name ascending; supports_nodes and runtime_capabilities as sorted strings; runtime_lookups by name ascending, each lookup's params and result_fields by name ascending.
  • runtime_lookups entries are stripped to {name, params, result_fields} before hashing — per-lookup display_name and description are catalog metadata and are excluded. params and result_fields are both lists of InputField ({name, type, required}; required omitted when false), each sorted by name before hashing. An empty list hashes as null, never [].
  • separators=(",", ":"), sort_keys=False at outer level (sorting is done explicitly in the listed slice fields, not by alphabetic key order).

JSON shapes

  • Heartbeat: {service, service_version, manifest_hash, started_at, now, shutting_down}. All fields always present (no omitempty).
  • CompletionEvent: {flow_run_id, flow_id, flow_version, node_id, node_type, service, started_at, finished_at, status, error?, trace_id?}. service is ALWAYS present. error and trace_id are omitempty. There is NO result body field; action output routes through the flow graph.
  • DLQEntry: {kind, envelope, body, err_msg, occurred_at}. envelope is the FULL nested Envelope object (10 fields), not a string. body is the original action input as raw JSON.
  • Manifest: top-level {protocol_version, service, service_version, manifest_hash, actions[], service_sources[], supports_nodes[], supports_rate_limit?, runtime_capabilities[]?, runtime_lookups[]?, ...catalog fields omitempty}.
  • RegisterReply: {error?, flows[]} where each FlowEnvelope is {id, version, body}.

Runtime capability compatibility

The manifest protocol remains 1.0.0. runtime_capabilities is an additive optional field and is omitted when empty, preserving the legacy wire shape and behavioral hash. When present, it is sorted and included in the hash. Notify accepts compatible protocol majors, while release validation requires exact durable_timer.v1 on every service in a Durable Timer lineage. This capability gate prevents old or partially configured SDKs from receiving a Durable Timer graph without a protocol minor-version bump.

runtime_lookups is likewise additive and optional, omitted when empty, so a service declaring no lookups keeps its existing wire shape and hash byte-for-byte. It declares reads the engine may issue to the service (service->engine); it is not a runtime_capability (engine->service) and not a data_source (studio->service, design-time). Byte-level cross-language coverage lives in notify_triggers/testdata/lookup_v1_golden.json, asserted by all three independent hash implementations.

For each business service-in on that lineage, release also requires transport nats and a nonempty config.target exactly equal to the default of one active manifest service_sources[] entry with transport nats. Matching only the transport kind fails with durable_timer_target_mismatch.

Durable Timer private context

Trusted internal NATS deliveries carry the private triplet X-Triggers-Durable-Timer-Context, X-Triggers-Execution-Id, and X-Triggers-Durable-Timer-Payload-SHA256. The execution ID must equal the context's root_execution_id. The payload header is the lowercase SHA-256 of the exact current-hop bytes. The root context has exactly v, kind, flow_id, flow_version, and root_execution_id; elapsed context additionally carries the Timer node/reference, occurrence/fence, immutable original snapshot_sha256, resume edge, and resume run ID. The snapshot hash is not compared with a mapped or action-result payload. The triplet is unsigned by design and is stripped before action handlers, Kafka, HTTP, or output-topic routing.

Example manifest

Minimum-shape registration payload (no transports, no rate limit, one action, one source):

{
"protocol_version": "1.0.0",
"service": "my-python-svc",
"service_version": "dev",
"manifest_hash": "sha256:6f3a...c91d",
"actions": [
{
"name": "send_inapp",
"input_schema": {"type": "object", "properties": {"tenant_id": {"type": "string"}}, "required": ["tenant_id"]},
"result_schema": {"type": "object", "properties": {"delivered": {"type": "boolean"}}, "required": ["delivered"]},
"timeout_ms": 30000,
"retry": {"max_attempts": 3, "initial_backoff_ms": 100, "backoff_factor": 2.0, "max_backoff_ms": 5000},
"dlq": "triggers.v2.dlq.my-python-svc.send_inapp"
}
],
"service_sources": [
{"name": "nats", "transport": "nats", "default": "triggers.v2.mysvc.inbound"}
],
"supports_nodes": ["condition", "lookup", "mapping"]
}

Pydantic JSON Schema normalization

Pydantic v2's model_json_schema() produces $ref/$defs for nested models and {"anyOf": [{"type": "T"}, {"type": "null"}]} for Optional[T]. Go's reflection schema (triggers-sdk-go/schema/schema.go) inlines nested models and uses {"type": ["T", "null"]}.

The facade post-processes Pydantic's output to match Go (triggers/wire/schema_norm.py):

  1. Inline all $defs by resolving every $ref; strip $defs from the final document.
  2. Convert {"anyOf": [{"type": "T"}, {"type": "null"}]} to {"type": ["T", "null"]}.
  3. Preserve required: [...] at object level.
  4. Preserve additionalProperties for maps.

Without this step, identical-shape services would produce different manifest hashes between Python and Go.

Action chaining (result_event)

Studio resolves downstream field bindings from upstream action results using per-action result_event catalog metadata (event_type, schema_id, schema_version, fields). For flat typed object outputs with derivable fields, the SDK auto-derives that metadata, so manual result_event declaration is not required for the normal chaining path. Empty outputs omit it, and non-derivable shapes should opt out or override/customize it explicitly with result_event=.... Pass emit_result_event=False to opt out. v2 declarations must not include topic. result_event is excluded from the manifest hash (same as Go). See examples/11_flow_lab/ for a local E2E dummy service.