Sidecar protocol¶
A service written in a language other than Scala runs as a process beside the ankka sidecar, which is the ankka runtime started without a Scala service in it. The two speak a protobuf protocol over gRPC. The process owns decisions: given this command and this state, what should happen. The sidecar owns everything stateful and distributed: sharding, the journal, snapshots, projections, timers, HTTP, cluster formation, observability and the agent loop.
This page is for people writing or debugging an SDK. A service's own code never sees the protocol; the SDK
does. The definitions are in
protocol/src/main/protobuf/ankka/protocol/v1,
and the encoding of the bytes inside a payload is in
protocol/ENCODING.md.
Transport¶
Both sides listen on loopback only, inside the same pod, and neither binds any other interface.
| Server | Default address | Variable | Serves |
|---|---|---|---|
| The process | 127.0.0.1:9010 |
ANKKA_PROCESS_PORT (process), ANKKA_PROCESS_ADDRESS (sidecar) |
Every service except Client |
| The sidecar | 127.0.0.1:9011 |
ANKKA_SIDECAR_PORT (sidecar), ANKKA_SIDECAR_ADDRESS (process) |
Client |
The sidecar dials the process; the process dials the sidecar only to call other components, query views and schedule timers. On the platform both containers are given these variables and a descriptor may not set them.
Discovery¶
Discovery is the first conversation. The sidecar calls Discovery.Discover with its protocol version and
runtime version, retrying with backoff until the process answers or ANKKA_SIDECAR_DISCOVERY_TIMEOUT
(60 seconds by default) passes. The process answers with a Spec:
- its protocol version,
"1.22"; - its SDK's name and version;
- every component: its kind, its component id, and its handlers, each with a wire name and whether it is read-only or streaming, plus the kind's details — snapshot frequency, and whether it is replicated, for an event sourced entity; steps and settings for a workflow; the source (or, for a keyed view, the sources), row manifest, queries and declared queries for a view; the source and topic for a consumer, and whether a consumer that publishes is given mirrored changes; the tools, with descriptions and JSON Schemas, and guardrails for an agent; and for an autonomous agent its whole definition — description, instructions, tools, guardrails, model, task types with their result schemas and rule names, and the types it accepts with their iteration budgets;
- every HTTP endpoint: its prefix, ACL and routes, where a route may carry an ACL of its own that replaces
the endpoint's for that route alone — a route that carries none is served under the endpoint's — and
may be a socket route, always a
GETwith no body.
The sidecar validates the whole Spec and hosts exactly what it describes. If anything is wrong it calls
Discovery.ReportError once, with every problem, and refuses to start. A process should log what it is told;
this is where a developer sees why their service did not start.
Services¶
The table is generated from the .proto files.
| Service | RPC | Request | Response | Defined in |
|---|---|---|---|---|
Agent |
Plan |
PlanRequest |
PlanReply |
agent.proto |
Agent |
InvokeTool |
ToolRequest |
ToolResult |
agent.proto |
Agent |
CheckGuardrail |
GuardrailRequest |
GuardrailResult |
agent.proto |
Agent |
CheckTaskResult |
TaskResultRequest |
TaskResultVerdict |
agent.proto |
Client |
Invoke |
InvokeRequest |
InvokeReply |
client.proto |
Client |
InvokeStream |
InvokeRequest |
stream StreamToken |
client.proto |
Client |
Query |
QueryRequest |
QueryReply |
client.proto |
Client |
Schedule |
ScheduleRequest |
Empty |
client.proto |
Client |
Cancel |
CancelRequest |
Empty |
client.proto |
Client |
GetSecret |
GetSecretRequest |
GetSecretReply |
client.proto |
Client |
PutSecret |
PutSecretRequest |
PutSecretReply |
client.proto |
Client |
DeleteSecret |
DeleteSecretRequest |
DeleteSecretReply |
client.proto |
Client |
Request |
ServiceRequest |
ServiceReply |
client.proto |
Client |
Decide |
DecideRequest |
InvokeReply |
client.proto |
Client |
ScheduleRecurring |
ScheduleRecurringRequest |
ScheduleRecurringReply |
client.proto |
Client |
QueryStream |
QueryRequest |
stream RowFrame |
client.proto |
Client |
Watch |
WatchRequest |
stream RowFrame |
client.proto |
Client |
AwaitEnd |
AwaitEndRequest |
InvokeReply |
client.proto |
Client |
AwaitEndStream |
AwaitEndRequest |
stream StreamToken |
client.proto |
Client |
IssueClientKey |
IssueClientKeyRequest |
IssueClientKeyReply |
client.proto |
Client |
ListClientKeys |
ListClientKeysRequest |
ListClientKeysReply |
client.proto |
Client |
RevokeClientKey |
RevokeClientKeyRequest |
RevokeClientKeyReply |
client.proto |
Client |
GetSubjectKey |
SubjectKeyRequest |
SubjectKeyReply |
client.proto |
Client |
SubjectNotices |
SubjectNoticesRequest |
stream SubjectNotice |
client.proto |
Client |
AckSubjectNotice |
SubjectNoticeAck |
SubjectNoticeAckReply |
client.proto |
Client |
AskErasure |
AskErasureRequest |
AskErasureReply |
client.proto |
Consumer |
Handle |
ConsumerRequest |
ConsumerEffect |
consumer.proto |
Discovery |
Discover |
SidecarInfo |
Spec |
discovery.proto |
Discovery |
ReportError |
Problem |
Empty |
discovery.proto |
Http |
Handle |
HttpRequest |
HttpReply |
endpoint.proto |
Http |
HandleStream |
HttpRequest |
stream StreamFrame |
endpoint.proto |
Http |
HandleSocket |
stream SocketIn |
stream SocketOut |
endpoint.proto |
Erasure |
Invoke |
ErasureCall |
ErasureReply |
erasure.proto |
EventSourced |
Handle |
stream EventSourcedIn |
stream EventSourcedOut |
event_sourced.proto |
KeyValue |
Handle |
stream KeyValueIn |
stream KeyValueOut |
key_value.proto |
Mcp |
InvokeTool |
McpToolRequest |
McpToolReply |
mcp.proto |
TimedAction |
Invoke |
TimedActionRequest |
TimedActionEffect |
timed_action.proto |
View |
Handle |
ViewRequest |
ViewEffect |
view.proto |
Workflow |
Handle |
stream WorkflowIn |
stream WorkflowOut |
workflow.proto |
| File | Conversation |
|---|---|
payload.proto |
Shared messages: Payload, Metadata, Outcome, Retention, Error, Failure. |
discovery.proto |
The process describes its components and endpoints. |
event_sourced.proto, key_value.proto, workflow.proto |
One bidirectional stream per loaded instance. |
view.proto, consumer.proto, timed_action.proto |
Stateless: one request, one effect. |
endpoint.proto |
HTTP requests the sidecar forwards for declared routes, with streaming for server-sent events. |
agent.proto |
The process plans, runs tools and guardrails, and checks autonomous agents' results; the sidecar runs the loop. |
client.proto |
The sidecar's service for the process: component calls, streaming calls, view queries, timers. |
Stateful conversations¶
An event sourced entity, key value entity or workflow instance is a bidirectional stream, opened when the
sidecar loads the instance. For an event sourced entity the sidecar sends Init with the latest snapshot, if
any, then the events after it, then commands one at a time. The process folds the events into state and
answers each command with a Reply — the events to persist, any retention, and an Outcome that is a reply
or a refusal — or with a Failure when the handler faulted. An Init with no snapshot means a fresh
instance, or one that was deleted or expired; start from the empty state.
When a command asks for a snapshot, the reply carries the state after its events. The sidecar stores it beside the journal; the stored snapshot may sit at the sequence of the last event the reply persisted or at the next one, and both are correct.
For a replicated entity the conversation says where it runs. Init
carries installation, this installation's name; each Event carries installation, the installation
that wrote it, so an event whose installation is not this one is a mirrored change; and each Command
carries installation and at_home, whether this installation is the entity's home. A command that may
persist reaches the process only at the home: the runtime forwards it there from any other installation,
so at_home is false only for a read-only command. A Reply may carry replication, a
ReplicationChange that is either hand_over_to, an installation, or set, installations to add and
remove; the runtime refuses it from an entity not discovered as replicated. The records the runtime keeps
for the home, the replication set and the handover never reach the process. All of these fields are empty
on a service that names no installations.
Each Command also carries attribution, the subject of the principal who made the original call (a
person's subject, a deploy token's token:<id>, a machine's client id), and forwarded_from, the
installation that forwarded the command to its home. Each event the command persists is recorded with
both, and each Event replayed to the process carries them as recorded. Both are empty when there is
nothing to say: a call no authenticated principal made, or a command that was not forwarded. Nothing else
about the principal reaches the process, and never its credential.
A consumer's request carries ankka.installation, the installation that wrote a replicated entity's
change, and ankka.local, "true" or "false", in its metadata. A consumer with a publication is sent
only local changes unless it declared mirrored_changes.
Autonomous agents¶
An autonomous agent has no plan to ask for: its definition arrived in discovery, and the sidecar runs its
loop, its task records and its instance records. It asks the process for three things, each with the session
id task:<task id>: Agent.InvokeTool to run one of its tools, Agent.CheckGuardrail to check its
instructions or a result, and Agent.CheckTaskResult when the model completes a task. For the last, the
process decodes the result as the task type's and runs the type's rules in order, answering accept,
malformed with why it did not decode, or reject naming the first rule that refused it. A rule that throws
is answered as an error, which the sidecar treats as a failed iteration and tries again. The sidecar never
sends complete_task or fail_task to the process; they are its own.
Stateless conversations¶
A view, consumer or timed action is called once per change or timer, with a payload and metadata, and answers with one effect. An HTTP route is called once per request, or once per stream for a server-sent events route.
A socket route is one Http.HandleSocket call for as long as its socket is open. The sidecar holds the
socket: it decides the ACL when the socket is opened, enforces the frame limits, pings a quiet socket and
sends the close code. It sends open first — the opening request, with its path arguments, query,
headers, principal, caller and metadata, whose ankka.protocol entry states the runtime's version — then
a frame for each frame the client sent, in order, and closed with the reason once the socket is
closed, after which it ends its side. It reads the process's side only as the client can take it, and
sends a client's frame only when the process can take one, so a process that does not read leaves the
frames to the socket's own unread bound. The process sends a frame for each frame for the client and
ends with completed, which closes the socket 1000, or failed, which closes it 1011; a process that
stops or goes away is failed. A message with no case set, in either direction, is a protocol violation
that closes the socket as failed, never one that is skipped.
A consumer's effect is one of four: produce, one message for the consumer's topic; produce_all,
several; done; or ignore.
message ConsumerEffect {
oneof effect { Produce produce = 1; Empty done = 2; Empty ignore = 3; ProduceAll produce_all = 4; }
message Produce { Payload payload = 1; Metadata metadata = 2; }
message ProduceAll { repeated Message messages = 1; }
message Message { Payload payload = 1; Metadata metadata = 2; optional string key = 3; }
}
| Reply | Published | The change is handled when |
|---|---|---|
produce |
one record, keyed by the message's subject | the broker accepts it |
produce_all with messages |
one record each, in the order given; a message's record key is its key, and without one its subject |
the broker has accepted all of them |
produce_all with none |
nothing | at once, as done |
done, ignore |
nothing | at once |
Each message's ce-subject defaults to the source's id, whatever its key. If the broker refuses any
message of a produce_all, the change is delivered again and every message is published again; those
already accepted are not withdrawn. An empty key fails the change. A reply may be at most 4 MiB: a larger
one fails the change, naming the consumer, and none of it is published.
Payloads¶
Every value crosses the protocol as a Payload: content_type, manifest and data. The sidecar never
reads data. It stores it under its manifest exactly as the Scala runtime stores a Scala value, which is
what lets a service in one language read a journal written by the same service in another — as long as both
languages' codecs agree. Every SDK's default codec produces and accepts exactly this:
| Content type | Used for | Encoding |
|---|---|---|
application/json |
Records and sum types | A JSON object with every field written, including empty collections and absent options as null. A sum type's case adds "type": "<CaseName>". Instants are ISO-8601 in UTC with Z. |
text/plain |
Top-level primitives | The text itself, with no quotes: manifests string, int, long, short, byte, double, float, boolean, and duration-millis for a duration in milliseconds. |
application/octet-stream |
Done, unit, bytes, top-level options |
Zero bytes for done and unit; the bytes for bytes; for option[<manifest>], zero bytes for none, or 0x01 followed by the value's bytes. |
Readers are lenient where writers are strict: an absent optional field reads as none, field order does not
matter, and unknown fields are ignored. A missing required field or an unknown type is refused. Field names
are the contract, so a Python field matching a Scala one is productId, not product_id.
The fixtures in
protocol/fixtures are encoded
examples of every shape. An SDK decodes each fixture's bytes and compares the result with its value, then
re-encodes the value and compares the bytes. A fixture with no matching codec is a failure, not a skip.
Errors¶
A refusal is an Error with a message and an ErrorCode, carried in an Outcome. A fault is a Failure,
which names the command it answers. The two are never interchangeable: a refusal is a decision the handler
made, and a failure is a handler that could not decide. See Error codes.
Versioning¶
The protocol version is MAJOR.MINOR, currently 1.22, and both sides state it in discovery. 1.1 added
the caller to forwarded requests and caller-naming ACLs; 1.2 added the autonomous agent; 1.3 added a
consumer's reply of several messages, each with an optional record key, and the ankka.protocol entry
on a consumer's request; 1.4 added metadata to a workflow step, a tool call, a guardrail check, a result
check and a view query, so a call or a query made from any of them carries its trace and says which handler
made it; 1.5 added every other claim of a verified token, and the name of the issuer
that verified it, to a route's principal; 1.6 added the service's secret store, GetSecret, PutSecret and
DeleteSecret on Client, each answering a refusal in its reply. A process built for 1.6 that calls the
store on an earlier runtime is answered UNIMPLEMENTED, which each SDK reports as the runtime being too
old for the store. 1.7 added where a view's or a consumer's topic source starts, and the version it
reads at. 1.8 added Request on Client: a call to another service, which the runtime makes
as the service, answering the service's answer, a failure naming why no answer came, or a refusal. A
process built for 1.8 that calls it on an earlier runtime is answered UNIMPLEMENTED, which each SDK
reports as the runtime being too old to call another service; a process that declared an earlier minor
and sends one all the same is refused, naming both versions. 1.9 added socket routes: Route.socket
in discovery and Http.HandleSocket. A socket route is refused from both ends across that line: the
sidecar refuses a Spec declaring one under an earlier minor, naming the route and both versions, and an
SDK refuses to answer discovery with one to a runtime that states an earlier version. 1.10 changed no message: it added three imports for a WebAssembly module, request, now and random, which the WebAssembly ABI describes. A process is the same at 1.9 and 1.10. 1.11 added approvals, MCP servers and result
guardrails to agents: a tool's approval and an agent's mcp_servers and result_guardrails in discovery,
the RESULT stage of CheckGuardrail with the MCP tool it checks, the approval case of InvokeReply and
StreamToken that answers a turn waiting for a person, and Decide on Client, which sends a person's
decision, and the event case of StreamFrame, which sends a server-sent event under a name of its own — an
approval request, say — from a stream route. The sidecar connects to the MCP servers and enforces approval itself: a process is never asked to
run an MCP server's tool, nor a tool that awaits a decision. A process built for 1.11 that decides on an
earlier runtime is answered UNIMPLEMENTED, which each SDK reports as the runtime being too old. 1.12 added recurring timers:
ScheduleRecurring on Client, with a delay and a period and a refusal in its reply, and ankka.due on every
timed action's request. A recurring timer is a call of its own rather than a field on ScheduleRequest
because an earlier runtime reads a field it does not know as absent, and would schedule a timer meant to
recur to fire once; it answers the call UNIMPLEMENTED instead, which each SDK reports as the runtime being
too old for recurring timers. 1.13 added a view's declared queries, the keyed view and a version on a
view that reads entities. 1.14 added a topic source's contract, broker and parallel reading and a
consumer's publication. 1.15 added cross-project access: the granted-caller matcher, the machine
caller, and another project's topic on a source and a publication. 1.16 added a workflow as the
source of a view or a consumer: a
Source.ComponentRef of kind WORKFLOW, and WorkflowStanding, where the workflow stood once the effect
that recorded the state was applied, as standing on ViewRequest and ConsumerRequest. A workflow
source is refused from both ends across that line: the sidecar refuses a Spec declaring one under an
earlier minor, naming the component and both versions, and an SDK refuses to answer discovery with one to a
runtime that states an earlier version.
1.17 added a view's query answered as a stream and a watch: QueryStream on Client, which answers
a QueryRequest as RowFrames with no limit unless one is given, Watch, which answers a WatchRequest
for a declared query or one row with the rows now, one caught_up frame, then rows and removals as the
view writes, and DeclaredQuery.watched in discovery. A runtime before 1.17 answers both calls
UNIMPLEMENTED, which each SDK reports as the runtime being too old for view streams, and the sidecar
refuses a Spec declaring a watched query under an earlier minor.
1.18 added waiting for a
workflow's end: AwaitEnd on Client, answered with the state the workflow ended with, or an Error whose
code is WORKFLOW_FAILED with the step and the reason in details, or TIMEOUT; AwaitEndStream, the
same wait as a stream of heartbeat tokens and one ended or failed token; the WORKFLOW_FAILED code
and Error.details; and the await_end import for a module. An earlier runtime answers either call
UNIMPLEMENTED, which each SDK reports as the runtime being too old to wait, and refuses a module that
imports await_end when it loads.
1.19 added replicated entities: EventSourcedDetail.replicated and ConsumerDetail.mirrored_changes in
discovery; installation on Init and Event, installation and at_home on Command, and
replication on Reply in the event sourced conversation; the consumer's ankka.installation and
ankka.local metadata; installation and mirrored on a module's FoldRequest; and the NOT_HOME
code. A replicated entity is refused from both ends across that line: the sidecar refuses a Spec
declaring one under an earlier minor, naming the entity and both versions, and an SDK refuses to answer
discovery with one to a runtime that states an earlier version.
1.20 added MCP endpoints: Spec.mcp_endpoints with McpEndpoint, McpTool and McpAcl in
discovery, the Mcp service the process serves (InvokeTool: the sidecar has answered the MCP
protocol, applied the ACL and checked the arguments, and the process runs the tool with the principal
and the caller, never the credential), and the client key calls on Client (IssueClientKey,
ListClientKeys, RevokeClientKey), with the ankka1_mcp_tool export and three imports of the same
names for a module. The sidecar refuses an MCP endpoint from an SDK declaring an earlier minor, an SDK
declaring one refuses discovery from a runtime that states an earlier one, and an earlier runtime
answers the client key calls UNIMPLEMENTED.
1.21 added the service's object storage for a module: the storage import with StorageRequest and
StorageReply. No call on Client carries it, since a process reaches its buckets over S3 itself; an
earlier runtime refuses a module importing storage when it loads.
1.22 added who acted and where a write came from: attribution and forwarded_from on an event
sourced entity's Command and Event, and on a module's FoldRequest. Each is empty when there is
nothing to say, so a process that ignores them reads 1.22 as 1.21.
- Adding an optional field, a message, an RPC or a fixture is a minor change. A sidecar speaking a later minor accepts an SDK that declares an earlier one.
- Renaming, removing or changing the meaning of anything, or changing the encoding of a shape the fixtures
cover, is a major change. The sidecar refuses a
Specwith another major, naming both versions.
A process-hosted service declares its protocol in its descriptor's protocol field, and the platform checks
it before starting anything: the same major as the platform's, and a minor no later than its own.
Without a process¶
A service built to a WebAssembly module speaks these same messages without gRPC: the runtime loads the
module into its own process and calls the functions it exports, with a few envelopes of the module mode's
own in wasm.proto carrying the state the runtime holds for it. Discovery, the payload encoding, the
fixtures and the conformance suite are the same. See WebAssembly ABI.
Rules the messages do not state¶
These are part of the protocol, and an SDK that ignores one misbehaves in ways the types cannot show.
- One command in flight per stateful stream. The sidecar sends the next command only after the process has answered the last. A workflow's stream is the exception: the engine keeps answering commands while a step runs, so one command and one step may be in flight at once. A command arriving during a step is answered from the state before the step, and the step's new state applies when the step replies. An SDK must track the pending command and the pending step separately, and must not share per-request context between them.
- A workflow declares what the sidecar enforces. Timeouts and recovery live in
WorkflowDetail.settings, because the process cannot enforce them. Absent, there is no overall limit, each step has 30 seconds, and a failed step fails the workflow. Afailover_tostep must be declared, and runs with no input. - A step's fault is a
Failure; a step'sfailis a decision. A step that answersFailure, or times out, is retried and failed over as its recovery declares. A step that answersStepOutcomefailends the workflow, because that is what it asked for. An SDK turns an exception in a step into aFailure, never intofail. - A view or consumer learns of its source's deletion by a request with
deleted = trueand no event. The default answer is to delete the row, for a view, or ignore it, for a consumer. The source's id travels as the metadata entryce-subject, and the change's sequence number asankka.sequence: an event's sequence number, a key value entity's revision, and0for a topic's message. A deletion has a sequence number of its own, above every earlier change to the entity, for both kinds of entity. - A keyed view's change names its source and carries no row. A view declared with
sourcesrather thansourceis sent each change withsource_id, the component it came from, and answersrows: the rows to write and to delete, by key, in order, a later change to a key winning; an empty list is nothing. It reads its own rows throughClient.Query—getby key, or one of its declared queries by name withvalues. The runtime handles one of its changes at a time and writes all of a change's rows together or none. - A declared query is asked by name with its values.
QueryRequest.namenames one of the view's declared queries andvaluesgives each:namethe statement holds, as text;limitbounds the rows, 1000 when absent. The statement is the runtime's to check when the process is discovered: a process sends it as the developer wrote it and parses nothing. - An SDK refuses a runtime older than what it declares. One that declares a keyed view, a declared query or a version on a view that reads entities refuses discovery from a runtime below 1.13, as one that declares a start position refuses a runtime below 1.7: an older runtime would ignore the declaration.
- An SDK answers
produce_allonly to a runtime that says it accepts it. A consumer's request carries the metadata entryankka.protocol, the protocol version the runtime speaks. A runtime that does not know a reply reads it as no effect and records the change as handled, so an SDK about to answerproduce_allto a request with noankka.protocol, or one below1.3, fails the request instead, saying which version it was given and which it needs. A handler that returns one message with no key is answered asproduce, and one that returns none asdone, on a runtime of any version. - A timed action's payload is what the process scheduled, carried through the timer table unread. The
timer's name, the attempt count and the due time the run is for arrive as metadata
ankka.timer,ankka.attemptsandankka.due, the last in milliseconds since the epoch. Afail, an exception or an unreachable process is retried on the sweeper's schedule with the count incremented and the sameankka.due. - An agent plan names; it does not carry.
AgentPlan.model,toolsandguardrailsare names the sidecar resolves against its configuration and against discovery. The process is called back for a tool with the model's arguments as JSON text, and for a guardrail with the stage and the text. The loop, the session memory and the model key stay in the sidecar. A tool or guardrail call arrives after the handler that planned it has returned, so anything it needs from the session must be captured when the plan is made. ankka-calleris the runtime's, carried and never written. Metadata the runtime sends with a command, a step, a tool call or a check carriesankka-trace-id,ankka-span-idandankka-caller, the handler whose work this is. An SDK forwards a handler's metadata unchanged on the calls it makes through the client, which is how those calls are attributed; user code never setsankka-caller, and the runtime ignores one that does not name a component and handler the service declared.Unavailablemeans try again. When an instance stops while callers are waiting on it, for example during a rolling replacement, the sidecar answers each waiting caller withUNAVAILABLErather than letting it time out, and the sidecar'sClientretriesUNAVAILABLEbriefly before giving up. An SDK should treatUNAVAILABLEandTIMEOUTas retryable.