api/ directory; transport handlers, storage models, and state machines do not live there. For where this sits in the overall picture, see Architecture overview.
The constraints on api/ come from docs/specs/code-style.md §3.1: it holds only protocol structures, message envelopes, and field conventions; structs keep the role of “data objects” and may include a small amount of side-effect-free validation or encoding helpers, but must not depend on external IO.
gRPC services
All four proto files live inapi/grpc/<service>/, and the generated Go code lives in sibling *pb/ directories (workerpb/, coordinatorpb/, bundlecoordinatorpb/, syncinvokerpb/). Generation commands:
make proto is defined in the repo root Makefile; it generates all four services in one pass and also triggers proto-blockdb, proto-meta, and proto-localtestservice (these three belong to the backend adapters, with protos in internal/sdk/*/proto/). After changing a .proto, you must regenerate and commit the *pb/ directories together with it.
WorkerService (api/grpc/worker/worker.proto)
RequestTaskSlot(task_id, ttl_ms) -> (slot_id, state): called by the Coordinator;ttl_msonly controls automatic reclamation ofALLOCATEDslots and is not an execution timeout.SubmitTask(task: TaskInput, slot_id) -> state:slot_idmay be empty, in which case the Worker performs oneRequestTaskSlotinline.TaskInputfields:task_id,function_call_config{type, config bytes},result_handler{type, config bytes}(optional),task_timeout_ms(optional; 0 means the Worker default, and negative values or values exceeding the Worker’smaxTaskTimeoutMsare rejected). Bothconfigs are opaque JSON blobs; the Worker validates at the entry point that they are valid JSON (submitTaskFromPBininternal/worker/adapters/wire.go).GetTaskResult(task_id) -> (task_id, state, result?):resultappears only in terminal states.WatchTasks(task_ids) -> stream TaskUpdate: first pushes one current snapshot pertask_id, then one update withis_terminal=truefor each task as it reaches a terminal state. Atask_idthe Worker does not recognize is expressed with a non-emptyTaskUpdate.errorrather than failing the whole stream. It also serves as a run credential: once a task has had a Watch established, the disconnection of the last stream startswatchDisconnectGraceMs, and on timeout the task fails withWATCH_DISCONNECTED.- The
statefield is a string whose values come fromTaskStateininternal/common/types/types.go:ALLOCATED,RUNNING,SUCCEEDED,FAILED. - After the Client obtains
worker_addrthrough the Coordinator,SubmitTask/GetTaskResult/WatchTasksall go directly to that Worker; the Coordinator does not relay results.
client-id (with x-instance-id supported for compatibility), which the Worker normalizes at the SubmitTask boundary with internal/common/clientid; shadow forwarding is marked with x-blockx-shadow-submit: true, and the receiving side strips the ResultHandler (internal/worker/adapters/shadow_forwarder.go).
CoordinatorService and BundleCoordinatorService
ReserveWorkerSlot(task_id) -> (task_id, worker_addr, slot_id). The slot TTL is determined by the Coordinator’s configuration; there is no chainId or reserveReqId in the request.
bundle_coordinator.proto defines bundlecoordinator.v1.BundleCoordinatorService with an identically named method and identical fields; the two sets of messages do not reference each other. The sole purpose of splitting them is to let an upstream proxy route by method path (/coordinator.v1.CoordinatorService/ReserveWorkerSlot vs /bundlecoordinator.v1.BundleCoordinatorService/ReserveWorkerSlot). BundleCoordinatorServer in internal/coordinator/adapters/bundle_grpc_server.go only converts messages and then delegates to the shared CoordinatorServer. See Bundle clusters for details.
SyncInvokerService (api/grpc/syncinvoker/sync_invoker.proto)
- One unary request executes one top-level call. The request uses
oneof function_source { function_id | inline_source }(exactly one is required),args_jsonis the raw bytes of a JSON array, anddeadline_unix_msis an absolute deadline. - The response carries
call_id,success,result_jsonorFailure{code, message, retryable, origin},execution_duration_ms, andqueue_wait_ms.DebugInvokeadds aDebugOutput(stdout/stderr tail,error_stack,local_vars_json). FailureCodeandFailureOriginare proto enums, read directly from the proto; execution-phase failures are returned as response data (the gRPC status is still OK), and only admission-layer rejections produce a non-OK status, in which casecall_idis returned via the trailerx-blockx-sync-call-id(internal/syncinvoker/adapters/grpc_server.go).Precheckruns only the static audit without executing; a failed audit ispass=false+findingsin a normal response, and non-OK statuses are reserved for cases where no conclusion can be reached (FAILED_PRECONDITIONwhen auditing is disabled,UNAVAILABLEwhen the auditor is faulty,INVALID_ARGUMENTfor empty source code).
Error model
BlockX distinguishes two layers of errors, following the rules indocs/specs/code-style.md §7:
- Admission / protocol errors: invalid arguments, invalid slot, no capacity, service not ready. These are returned as gRPC statuses and never enter
TaskResult. - Execution terminal states: Builder / Call / Plugin / timeout failures after the task has been activated. These converge into
TaskResult, and the gRPC status is OK.
internal/common/types/types.go:
workerCodeToGRPC in internal/worker/adapters/wire.go, the Coordinator-side mapping is coordCodeToGRPC in internal/coordinator/adapters/grpc_server.go, and the reverse mapping used when the Coordinator calls the Worker is grpcErrToSlotError in internal/coordinator/adapters/worker_rpc.go:
UDS protocol (Worker ↔ Executor)
The Worker listens on<SocketDir>/blockx-worker-<pid>.sock (NewAdapter in internal/worker/adapters/executor/adapter.go); the Executor process connects on its own after starting and attaches via its first Heartbeat. The Sync Invoker reuses the same executor adapter and the same protocol. For field semantics see the comments in api/uds/types.go and docs/specs/worker-executor-connection-and-python-sdk-hook.md §4.
Frame format
api/uds/codec.go defines two frame types, with a maximum size of 16 MiB (maxFrameSize):
BXB1 is a self-describing magic: messages without a sidecar are byte-for-byte identical to the old format; frames with a sidecar let protobuf bytes (for example BlockDB requests / responses) cross the Worker/Executor boundary without base64. The JSON object is a MessageEnvelope:
ProtocolVersion is currently always "v1" (internal/worker/adapters/executor/send.go). TraceContext carries W3C traceparent / tracestate, used to let span chains cross the UDS.
Codec entry points
The Go side uses
bytedance/sonic for speed, and the wire bytes are identical to encoding/json (test cases such as TestWriteFrameStdlibCanReadSonicOutput in api/uds/codec_test.go pin this down).
Message types and payloads
Constants inapi/uds/types.go:
The corresponding Python implementation:
python/blockx_executor/wire_keys.py (field names and message type constants MT_* / F_* / FP_*), python/blockx_executor/messages.py (the MessageEnvelope dataclass), python/blockx_executor/uds_codec.py (encode_frame, FrameDecoder, dumps_wire / loads_wire, which also recognizes BXB1), and python/blockx_executor/uds_session.py (UDSSession, a single-connection reader/writer). Cross-language contract tests live in python/tests/test_executor_protocol_contract.py and python/tests/test_uds_codec.py. When changing payload fields, both the Go and Python sides must be updated; see Python Executor.
etcd registration and heartbeat
api/etcd/registry.go defines the registry prefix constants and parser:
ParseRegistryPrefix(prefix)accepts only two formats: the two fixed legacy paths (mapped to an implicitprod/default), or the v2 form/blockx/<env>/lanes/<lane>/{workers|bundle-workers}/. It must end with/, and segments may only contain lowercase letters, digits, and interior hyphens.- The Worker key is
prefix + workerAddr(key()ininternal/worker/adapters/etcd_publisher.go), and the value is the JSON ofWorkerHeartbeat, bound to a lease (default TTL 10s) that is renewed byKeepAlive;Deregisterrevokes the lease, which deletes the key. Heartbeats are sent every 200ms by default (HeartbeatIntervalMs; 2000ms recommended in production). - Default prefixes per entry point:
cmd/workeruses/blockx/workers/,cmd/bundle_workeruses/blockx/bundle-workers/;cmd/coordinatorwatches the legacy block prefix, andcmd/bundle_coordinatorwatches both the legacy and the prod v2 bundle prefixes, overridable withCOORDINATOR_REGISTRY_PREFIXES. The Coordinator only watches an exact list of prefixes; watching the/blockx/root is not allowed.
api/etcd/types.go):
internal/coordinator/core/worker_view.go reuses it directly with type WorkerHeartbeat = etcd.WorkerHeartbeat, and decodeWorkerHeartbeat in internal/coordinator/adapters/etcd_watcher.go falls back to the key with the prefix stripped when workerAddr is empty. Environment and lane do not enter the heartbeat fields, nor the protobuf.
Task result model
TaskResult only expresses the task-level result and does not include per-call return details. The proto definition (worker.proto):
- The terminal state is expressed by the outer
state:SUCCEEDEDcorresponds tosuccess=true,FAILEDtosuccess=false. Codes such asTIMED_OUTare not separate terminal states but values ofexecute_result.failure_code. - Task-level
failure_codes that appear in the code:BUILDER_FAILED,CALL_FAILED,PLUGIN_FAILED,TIMED_OUT,WATCH_DISCONNECTED,SLOT_LOST(internal/worker/core/worker.go,internal/worker/core/dispatcher.go); the adapter side also usesACTIVATION_FAILED,IO_SCOPE_FAILED,BUILDER_NOT_FOUND,PLUGIN_NOT_FOUND,CANCELLED,UNKNOWN_FAILURE(internal/worker/adapters/orchestrator_phases.go). These are string constants with no centralized enum. PluginResult.resultis JSON bytes;ReturnValueResultHandlerputs the array of call outputs here to return to the Client.- The Go in-memory forms are in
internal/common/types/types.go(TaskResult/ExecuteResult/PluginResult/TaskUpdate), andtaskResultToPBininternal/worker/adapters/wire.goconverts them to proto.
The Go type
PluginResult has FailureMsg and Retryable fields, but the PluginResult in worker.proto has no corresponding fields, and taskResultToPB does not transmit them. The Client cannot get plugin-level failure messages, only failure_code.Conventions for changing protocols
Fromdocs/specs/code-style.md §3.1 and AGENTS.md:
- Prefer adding optional fields or new enum values. Do not silently change the meaning, type, or error-code semantics of existing fields; do not reuse deleted field numbers in proto.
- Any
api/change must update the corresponding spec (worker.md,task-resource-coordinator.md,sync-invoker.md,worker-executor-connection-and-python-sdk-hook.md, etc.) in the same change. Do not let structs in the code quietly become the new protocol truth. - Shared protocols are not scattered across adapters; they are consolidated in
api/. Types inapi/carry no business flow and do not depend on external IO. - When changing a UDS payload, Go (
api/uds/types.go) and Python (python/blockx_executor/wire_keys.pyand the corresponding payload construction) must be changed together, with test cases added topython/tests/test_executor_protocol_contract.py. Old peers tolerate new fields thanks to lenient JSON object semantics; old peers cannot consumeBXB1sidecar frames, so the Worker and Executor must come from the same image. - When changing the gRPC error mapping, change both the forward mapping in
wire.goand the reverse mapping inworker_rpc.go, and updateinternal/worker/adapters/wire_test.goandinternal/coordinator/adapters/worker_rpc_test.go. - When changing registry prefix rules, update
api/etcd/registry_test.goanddocs/specs/2026-07-28-registry-prefix-migration.md. - When splitting out a new gRPC service identity (such as the bundle coordinator), use a separate proto and separate messages with no cross-references, so that the method path itself carries the routing identity.
- Cross-component behavior is backstopped by the contract / system tests in
e2e/contract/(coordinator-worker,coordinator-etcd) ande2e/system/; see Testing.
Related docs
Specs in the blockx repo:docs/specs/code-style.md§3.1 (api/constraints) and §7 (error and result expression).docs/specs/architecture.md§4.3 (external results and protocols).docs/specs/worker.md§2 (RequestTaskSlot/SubmitTask/ result queries and subscriptions).docs/specs/task-resource-coordinator.md§2 (Client → Coordinator and Coordinator → Worker protocols).docs/specs/sync-invoker.md§3 (invocation protocol, including the full proto and Precheck).docs/specs/2026-07-23-bundle-coordinator-api.md(separate service identity for the bundle coordinator).docs/specs/client-id-propagation.md(client-id/x-instance-idpropagation rules).docs/specs/worker-executor-connection-and-python-sdk-hook.md§3–§5 (UDS connection model and message contract).docs/specs/2026-07-28-registry-prefix-migration.md(etcd registry legacy / v2 prefixes).