Agent Substrate 101
Install, run, connect, and debug stateful agent actors on Kubernetes, with a measured answer for each decision
- Substrate on One Page
- Start
- Actors
- Workers and Capacity
- Identity and Access
- Network
- Integrations
- Operate and Debug
- Build
How to read this manual
Install Agent Substrate on a kind cluster from the release images, wake one actor with a request, and then learn each part with the number we measured for it.
This manual is for the engineer who runs agents on Kubernetes and must decide if Agent Substrate fits. It starts with a cluster that answers a request. It ends with a table that takes you from an error text to its cause.
These commands create a kind cluster, install Agent Substrate v1.0.0 from the release images, and create one actor:
git clone --branch v1.0.0 https://github.com/agent-substrate/substrate.git
cd substrate
hack/create-kind-cluster.sh
export ATE_INSTALL_KIND=true
go run ./cmd/ate-setup deploy ate-system \
--credential-provider='{"name":"k8s.io"}' \
--ingress-allowed-spiffe-ids=spiffe://cluster.local/ns/ate-system/sa/atenet-client \
--image-repo ghcr.io/agent-substrate/substrate --image-tag v1.0.0
go run ./cmd/ate-setup deploy demo counter
go install ./cmd/kubectl-ate
export PATH="$(go env GOPATH)/bin:$PATH"
kubectl ate create actor my-counter-1 -a ate-demo-counter --template counter
kubectl ate proxy actor my-counter-1 -a ate-demo-counterThe last command stays in the foreground and listens on port 8000. In a second terminal, send the actor a request:
curl -X POST http://localhost:8000/The actor answers with a count, and each new request adds one. Section 2.1 runs the install step by step, with the output to expect and the fix for each error we hit. Section 2.3 explains the actor and the proxy.
You need Go 1.27, Docker, git, and kubectl. The release has no ate-setup binary, so the installer runs with go run from the checkout. The release does have kubectl-ate binaries, and section 2.2 installs one without Go.
We measured this install once on a second x86 server guest (16 vCPU, 23 GB). The kind cluster took 55 s, deploy ate-system took 162 s, and deploy demo counter took 94 s. The whole path took about five minutes. Raw runs
We measured the same install once on an Apple silicon laptop under Docker Desktop, with 10 CPU and 8 GB for Docker. The kind cluster took 119 s, and deploy ate-system took 702 s. Suspend and resume worked there with the gVisor sandbox class. The laptop and the server guest are different machines, so do not compare their seconds. Raw runs
Do not build from source for a first install. The command without --image-repo and --image-tag builds every image, and it needs the Docker buildx plugin and a Rust compile. On the x86 server guest (24 vCPU, 47 GB, nested KVM) it failed once and then took 266 s. That guest is another machine than the one above, so the difference in seconds is indicative only.
What you can do after this manual
- Install Agent Substrate on a kind cluster from the release images, create an actor, and send it a request.
- Predict how long an actor takes to resume from the memory that holds data in it, and choose between an asynchronous and a synchronous suspend.
- Size a WorkerPool, and say what a client sees when the pool is full.
- Write an egress policy, inject a credential, and choose between the Envoy and the agentgateway dataplane.
- Find the cause of a 500 or a 503 from its text, and bring a crashed actor back with
RevertActor.
Who this manual is for
You run agents on Kubernetes, or you plan to. You know pods, Services, and kubectl. You have not used Agent Substrate, and you want to know what it does and what it costs before you depend on it.
The Agent Substrate documentation describes each object and each flag. This manual tells you what happened when we ran them. We installed v1.0.0 on our own clusters, broke it on purpose, and timed it. Where our run disagrees with a page of the documentation, we say so and we show the recording.
We hold eight positions, and each one comes from a run. Section 1.1 lists them with the number behind each one. The first position matters most: Substrate does not suspend an idle actor, so something that you run must do it.
What we did not test
All our clusters but one had one kind node, and the sandbox class was gVisor in every run behind Parts 1 and 2. We did not run Agent Substrate on GKE. Our object stores outside the cluster ran on the same guest and on a second guest of the same server. We did not use a cloud bucket. Our scale runs used one node, with up to 300 workers and 300 resumes at one time.
The only real model in our runs was a small open model on CPU, behind kagent and behind Agent Executor. So no model time in this manual is the time of a GPU or of a hosted model.
Each section says so where one of those limits applies. How this manual was made describes the machines, the method, and the full list.
How the parts are ordered
Part 1 is Substrate on one page: what it does, and one request from the client to the actor. Part 2 installs it and creates your first actor. Part 3 is the actor: its template, its states, suspend, resume, and what a snapshot keeps.
Part 4 is capacity: worker pools, sandbox classes, and a full pool. Part 5 is identity, and Part 6 is the network in both directions. Part 7 connects kagent and agentgateway, and it says when Substrate is the wrong choice. Part 8 operates and debugs a cluster. The Reference holds the lookup tables:
| Part | What it covers |
|---|---|
| 1 · Substrate on One Page | What Substrate does and what it leaves to you, then one request from the client to a suspended actor and back. |
| 2 · Start | The install with ate-setup, the kubectl ate plugin, a first actor behind the proxy, and the objects that the install created. |
| 3 · Actors | The ActorTemplate and its golden snapshot, the seven states, suspend and resume with their measured cost, snapshot fidelity, CRASHED and RevertActor, and atespaces. |
| 4 · Workers and Capacity | The WorkerPool, the gVisor and microVM sandbox classes, placement, request parking, and how to size a pool. |
| 5 · Identity and Access | How a client authenticates, how an access policy limits an atespace, and what an actor JWT carries. |
| 6 · Network | The router and ingress, the egress policy and default deny, the DNS relay, TLS interception, credential injection, and the choice between Envoy and agentgateway. |
| 7 · Integrations | kagent 1.0 on Substrate, agentgateway with Substrate, Substrate on GKE, when Substrate is the right choice, and Agent Executor. |
| 8 · Operate and Debug | Postgres and the object store, what fails when a dependency is down, symptoms and their fixes, the upgrade path, and the limits at v1.0.0. |
| 9 · Build | An idle suspender, an agent actor that keeps its conversation, a sandbox for commands you do not trust, and a drill for six failures. |
| R · Reference | The methods, objects, states, commands, flags, errors, terms, and sources this manual uses, in one place. |
Each section ends with a short list of actions. Read those first when you need an answer today. Read the rest when you want the reason.
Four lessons go deeper into the ideas that this manual applies. The production runtimes lesson compares the ways to run an agent in production. The durable execution lesson and the checkpoints and rollback lesson build the ideas behind suspend, resume, and revert. The AI gateways lesson covers the gateway in front of a model. This manual stays with Agent Substrate.
Colour in figures
Each hue keeps one meaning in every figure:
| blue | the Substrate control plane: ate-api-server, ate-controller, ate-idp-server |
| violet | workers and sandboxes: atelet, worker pods, gVisor, microVM |
| green | the actor: the workload you run |
| plum | the agent harness and model calls: kagent, the model |
| olive | the client and hosts outside the cluster |
| indigo | the dataplane: atenet-router, atenet-egress, Envoy, agentgateway |
| amber | lifecycle: actor states and the steps between them |
| teal | durable state: Postgres, the object store, snapshots |
| rose | failure: a crashed actor, a 500, a 503, lost calls |
| grey | outside Substrate: Kubernetes, the node, the operator |
Figure 0.1 teaches the arrow styles with exchanges from our recordings. A solid arrow is a call, and a dashed arrow in the same ink is its reply. The other six styles each mark one kind of event, and the colour of the arrow matches the table above.
Substrate on One Page
Substrate runs many actors on few worker pods, and it leaves the decision to suspend an actor to you.
What Substrate does, and what it leaves to you
Substrate runs many stateful actors on few worker pods and resumes one when a request arrives, and it leaves the suspend call, the pool size, and GPU work to you.
You run one pod for each agent session. Most sessions wait for a person or for a model, and each waiting pod still holds its CPU and its memory. If you delete the pod, the session loses its memory. If you keep the pod, you pay for a process that does nothing.
When you finish this section, you can say what Agent Substrate does about that problem and name the jobs it leaves to you. You can also find the section that proves each of our eight positions.
The mechanism: actors, workers, and snapshots
Substrate separates the session from the pod that runs it.
Actor: one stateful workload with a name, such as my-counter-1. An actor has memory, files, and a root filesystem. It is a row in Postgres, and it is not a Kubernetes object.
Worker: one pod that can run actors inside a sandbox. A WorkerPool is the Kubernetes object that creates worker pods, and the pool has a fixed number of them.
Snapshot: the saved state of one actor. Substrate writes it when the actor is suspended and reads it when the actor is resumed.
Atespace: the named group that an actor, its template, and its policies belong to. An actor is always addressed by its atespace and its name.
A suspended actor uses no worker. It is a snapshot and a row. When a request arrives for it, Substrate chooses a worker with room, restores the snapshot there, and sends the request on. When something suspends the actor again, Substrate writes a new snapshot and frees the worker. Figure 1.1 shows the three worker pods of our demo pool with one actor that runs.
So a pool of three workers can hold hundreds of sessions, as long as few of them run at one time. That is the whole offer. The cost is the time to resume, and section 3.3 measures it against the memory that holds data in an actor.
What Substrate standardizes
Substrate gives you five things with fixed names.
- One API.
ate-api-serverserves theControlgRPC service for atespaces, templates, actors, snapshots, workers, and policies. Thekubectl ateplugin is its client. A second service,WorkerService, is forateletonly. - One lifecycle. An actor has seven states, and five operations move it between them: create, resume, suspend, revert, and delete. Section 3.2 draws them.
- One way to reach an actor. A client sends a request to
atenet-routerwith the headerate-target-actorand a client certificate. The router finds the worker. - Two sandbox classes. An actor runs under gVisor or in a microVM, and the WorkerPool decides which one. We ran both on one machine, and section 4.2 compares them.
- One egress rule. An actor reaches no host until an egress policy names that host. Section 6.2 shows the denial.
The project states that it keeps this API compatible from v1.0.0. That promise covers the API. It does not cover the jobs below.
What it leaves to you
The suspend call
Substrate does not suspend an idle actor. It has no idle timer. An actor that got one request stays in ACTOR_STATE_RUNNING and holds its worker until a caller sends SuspendActor.
We measured this on an x86 server guest with 24 vCPU and 47 GB, in one run. We sent five requests to my-counter-1 and then sent nothing for 900 s. We read the state every 15 s, and all 60 samples were ACTOR_STATE_RUNNING. Raw runs
The quickstart page of the project says that an idle actor is suspended for you. The API guide of the same project says that it is not. Our run agrees with the API guide. If you plan capacity from the quickstart sentence, your pool fills with idle actors.
The program that drives the actor has this job. kagent takes it: in our kagent run, 98 of 98 turns ended with a suspend. Section 7.1 shows that run. If you write your own harness, the suspend call is part of your harness.
The pool size
Substrate has no autoscaler. A WorkerPool has a spec.replicas field and a scale subresource, so a Kubernetes autoscaler can change it. Nothing in Substrate does. When every worker is full, a request for one more actor waits and then fails.
We measured that failure on the same guest, in eight requests to four actors of one run. Each request waited 5.06 s at the median (n=8) and then returned 503 with the text no worker has room for the actor. Section 4.4 explains the wait, and section 4.5 sizes a pool. Raw runs
GPU work
Substrate v1.0.0 does not give an actor a GPU. The API guide marks devices as not supported for now. We did not test GPUs. Keep model inference on a serving system, and let the actor call it through an egress policy.
Recovery
Substrate marks an actor as crashed, and then it waits for you. A crashed actor returns 503 on every request until a caller sends RevertActor. In one case it is not marked at all: a process that exits leaves the actor in ACTOR_STATE_RUNNING. Section 3.5 shows both cases.
We measured the exit case on the x86 server guest (24 vCPU, 47 GB, nested KVM), with two actors. Both read ACTOR_STATE_RUNNING for 20 minutes after the exit, until we called a suspend. One of them got 20 requests in that time, and all 20 returned 503. Each dead actor kept its CPU on the worker for all that time. Raw runs
Substrate also does not move an actor off a node that stops.
We measured this on a second x86 server guest (16 vCPU, 23 GB), on a cluster with two worker nodes, one run for each case. A suspended actor had its snapshot in the object store. It stayed in ACTOR_STATE_RESUMING on the stopped node for 5 min 33 s from its first request. It was crashed 5 min 44 s after the stop command. A running actor read ACTOR_STATE_RUNNING for the 10 minutes of our probe. The other node had free workers the whole time, and section 4.3 has the four cases. Raw runs
We measured 13 more cases on the same guest (16 vCPU, 23 GB), once each, to find what an operator can do. No actor left the stopped node by itself. One revert freed an actor in 7.1 s, and only for an actor that was suspended with --mode sync and had got no request. In the other cases a revert during the outage left the actor in ACTOR_STATE_REVERTING until the node was back. A new actor from a tag that we had made before the stop served a request 1.2 s after the create command. So you own the recovery from a node loss, and section 8.2 has the runbook. Raw runs
Substrate also has no opinion about the agent. It does not choose a model, run a tool, or keep a conversation. Those belong to the harness.
Our eight positions
Each position comes from a run on our own clusters. The table is the map of this manual.
We measured every number in this table on two x86 server guests, one run each unless the section says more. The section in the last column names the machine and the count. The guests are different machines, so do not set a number from one row against a number from another row. Raw runs
| Position | The number behind it | Section |
|---|---|---|
| Substrate does not suspend an idle actor | 60 of 60 samples RUNNING in 900 s | 3.3, 7.1 |
| A suspend is asynchronous, and its snapshot is not durable at once | PENDING on one node for 5 minutes | 3.4, 3.5 |
Postgres and ate-api-server are on every request path, and the Kubernetes API server is on none | 103 of 103 requests from a pod served while kube-apiserver was down for 86 s on Envoy, and 106 of 106 in 87 s on agentgateway | 1.2, 8.2 |
| Resume time grows with the memory that holds data in an actor | 0.22 s at 0 MiB, 2.16 s at 1000 MiB of random bytes, 0.23 s at 500 MiB of zero bytes | 3.3 |
| agentgateway is a dataplane option of Substrate, and it does authenticate ingress clients | 38 MB against 91 MB at idle | 6.6, 7.2 |
| An egress policy allows hosts and ports, and a removed rule does not close an open connection | a second request passed on an open pass-through connection, 20 s after the rule was removed. A suspend closed an open connection in 1.3 to 2.2 s | 6.2 for the policy, 6.6 for the open connection |
| A crashed actor is not always marked crashed | RUNNING for 20 minutes after the process exited, in 81 of 81 samples | 3.5 |
| Install from the release images | 162 s on the second guest (16 vCPU, 23 GB). The source build took 266 s on the first guest (24 vCPU, 47 GB), after a failed step. Two machines, so the seconds are indicative only. | 2.1 |
Read the positions as a list of things to check on your own cluster. Your seconds will differ. The states, the error texts, and the order of events must not.
What the project site says, and what we measured
The project site, ate.dev, reached version 1.0 with the release. We read it on 2026-11-12. Its documentation pages are built from the same files as the release tag, and we found no difference in content. Its first page makes short statements, some with numbers and none with conditions. The table sets each statement beside our result.
We measured every result in this table on x86 server guests, one run each unless the section says more. The section in the last column names the machine and the count. Raw runs
| The site says | Our result | Section |
|---|---|---|
| Idle agents suspend | Not seen. An idle actor stayed RUNNING for 900 s. The architecture page says that the caller requests the suspend. | 3.3, 9.1 |
An actor resumes in under 500ms | True for one resume, and for 10 at one time, up to about 200 MiB of data, on gVisor, from the node. 2.16 s at 1,000 MiB. 0.96 s at 200 MiB from the object store. With 50 resumes at one time on one node, the median was 1.2 to 1.3 s, and with 300 it was 7.3 to 7.4 s. | 3.3, 4.2, 4.5 |
| An actor resumes on any ready worker | Not seen for a node that stops. 5 of 5 resumes stayed on the node that held the snapshot. No actor left a stopped node by itself, in 17 cases. One revert at the right moment freed a quiet actor in 7.1 s. After a drain, 3 of 3 suspended actors did resume on the other node. | 4.3, 8.2 |
| The Kubernetes API is off the request path | Confirmed on both dataplanes. 103 of 103 requests from a pod were served while kube-apiserver was down for 86 s on Envoy, and 106 of 106 in 87 s on agentgateway. | 8.2 |
| Kubernetes handles recovery | True for nodes and pods. An actor on a stopped node needed an operator: a tag, a revert at the right moment, or the node back. After an eviction, the returned node stayed empty until we moved pods by hand. | 8.2, 3.5 |
| Memory and files survive a suspend | Confirmed with the memory fidelity. With the other two fidelities, the process starts again. | 3.4 |
| No inbound access without authentication | Confirmed for the two actor ports on both dataplanes. The stats port of the agentgateway router answered /metrics with no credentials. | 6.1 |
| No outbound access without a policy | Confirmed. A name gets NXDOMAIN, and an IP gets a reset or a 403. | 6.2 |
| The actor does not hold the secret | Confirmed for the placeholder swap. An upstream that returns its request headers gives the secret to the actor. | 6.5 |
500+/s suspend and resume activations | Not reproduced on one node. One node with 24 vCPU finished 32 to 39 resumes each second, with 50 to 300 at one time, in two runs. The control plane was not the limit, and our reading puts it in the gVisor restores on the node. We did not test more nodes, so this is no result for a larger cluster. | 4.5 |
10× the density of container runtimes | Not tested as stated. A suspended actor used no worker and 12.9 MB of node disk, and an idle worker used 16.7 MB of memory. We compared with no container runtime. | 4.5 |
| Millions of agents | Not testable on our machines. With 10,750 actor rows on one node, the database was 20 MB and a single resume took the same 0.36 s. | 4.5 |
| Worker state is scrubbed between runs | Not tested. The security page lists this guarantee as in progress. | no section |
| JWTs federate with every cloud provider | We decoded one token. We did not test federation. | 5.3 |
Three statements match our runs as written. Four hold under a condition that the site does not state. Two did not match what we saw. One we did not reproduce on one node, and four we did not test or could not test at our size. The two that did not match are the ones to plan for. You must send the suspend, and you must act when a node stops.
When this is the wrong tool
Substrate fits sessions that keep state and are idle most of the time. It does not fit an agent that runs all day, because that actor never frees its worker. It does not fit a workload that needs a GPU in the sandbox. Section 7.4 compares the choices, and section 8.5 lists the limits at v1.0.0.
One request, from the client to the actor and back
A request to a suspended actor passes the proxy, atenet-router, ate-api-server, and Postgres before a worker restores the actor, and a request to a running actor still asks all four.
You sent curl -X POST http://localhost:8000/ and a count came back. Six programs handled that request, and one of them wrote to a database before the actor ran. When a request fails later, the error text names one of those programs, so you need to know the order.
When you finish this section, you can name each hop of one request and say which hops a running actor skips. You can also say which parts of the cluster must be up for a request to pass.
The recording
We created my-counter-1 in the atespace ate-demo-counter and started kubectl ate proxy. Then we sent five requests, one after the other. This is the start of the recording. The columns are the time, the event, the HTTP status, the seconds at the client, and the body.
ts event detail
2026-11-12T10:14:21.286Z state ACTOR_STATE_SUSPENDED -
2026-11-12T10:14:21.837Z call-1 200 0.358379 preserved memory count: 1 | preserved file counter: 1 | preserved rootfs counter: 1
2026-11-12T10:14:21.944Z call-2 200 0.090770 preserved memory count: 2 | preserved file counter: 2 | preserved rootfs counter: 2
2026-11-12T10:14:22.030Z call-3 200 0.066903 preserved memory count: 3 | preserved file counter: 3 | preserved rootfs counter: 3
2026-11-12T10:14:22.109Z call-4 200 0.061842 preserved memory count: 4 | preserved file counter: 4 | preserved rootfs counter: 4
2026-11-12T10:14:22.196Z call-5 200 0.068053 preserved memory count: 5 | preserved file counter: 5 | preserved rootfs counter: 5
2026-11-12T10:14:22.203Z state ACTOR_STATE_RUNNING -We measured these requests on an x86 server guest with 24 vCPU and 47 GB, through kubectl ate proxy, in one run. The first request took 0.358 s. The next four took 0.091 s, 0.067 s, 0.062 s, and 0.068 s. Raw runs
The actor was ACTOR_STATE_SUSPENDED before the first request and ACTOR_STATE_RUNNING after the fifth. No command resumed it. The first request did.
Hop by hop
Figure 1.2 draws the first request. The numbers in this list match the rows of the figure.
curlsends the request to127.0.0.1:8000, wherekubectl ate proxylistens.- The proxy opens a port-forward through the Kubernetes API server to
atenet-router. Inside that port-forward it opens an mTLS CONNECT tunnel to router port 8444, with a client certificate. - The router reads the target actor and calls
ResumeActoronate-api-server. It does so for every request, because it keeps no record of where an actor runs. Requests that arrive at the same time for one actor share one call. ate-api-serverreads the actor row from Postgres. The state isACTOR_STATE_SUSPENDED.ate-api-servertakes a lease on the actor, chooses a worker with room, and writes the assignment to Postgres in one transaction.ate-api-servertellsateleton that node to restore the snapshot. The worker starts the sandbox from it, and the actor state becomesACTOR_STATE_RUNNING.ate-api-serverreturns the actor to the router, with the IP address of the worker pod and the identity of the worker.- The router opens a tunnel to port 8443 of the worker pod and checks that identity. The worker forwards the request to port 80 of the actor.
- The reply goes back the same way.
A new actor has never run, so the snapshot in step 6 is not its own. It is the golden snapshot of its template, and section 3.1 explains it. After step 7 the actor object carries the assignment:
"state": "ACTOR_STATE_RUNNING",
"workerAssignment": {
"nodeName": "kind-control-plane",
…
"workerEpoch": "1",
"workerNamespace": "ate-demo-counter",
"workerPod": "counter-7cc7dcd596-ffxwt",
"workerPodIps": [
"10.244.0.23"
],
…
"workerPool": "counter",
"workerSpiffeId": "spiffe://cluster.local/ns/ate-demo-counter/sa/ateworker"Where the 0.36 s goes
The first request paid for the restore. The four after it did not. The difference is about 0.29 s on this guest, for an actor that holds almost no memory.
The worker writes one log line when a restore starts and one when it ends. We did not save that log for the first request. We saved it for a later resume of the same actor:
{"time":"2026-11-12T10:30:17.846333794Z","message":"Actor restoring",…
{"time":"2026-11-12T10:30:18.098955499Z","message":"Actor restored",…We measured that resume on the same guest, once. The two log lines are 0.253 s apart, and the request that caused the restore took 0.382 s at the client. So about two thirds of a first request is the restore itself. Raw runs
That share grows with the actor. A restore reads the memory that the actor held, and section 3.3 shows 2.16 s for an actor with 1000 MiB.
A request to a running actor
A request to a running actor still does steps 1 to 4. ate-api-server reads the row, finds ACTOR_STATE_RUNNING, and returns at once. Steps 5 and 6 do not happen. So a request costs one call to ate-api-server and one read from Postgres, also when nothing is resumed. Requests that arrive together for one actor share that call.
Most of the 0.06 s in our recording is the proxy, not Substrate.
We measured both paths on the second x86 guest, with 16 vCPU and 23 GB, in the 30 s before a test. A pod that sent its requests to the router answered in 0.027 s at the median, with 30 requests. The same actor through kubectl ate proxy answered in 0.067 s at the median, with 28 requests. Raw runs
Use the number from the pod when you plan latency. The proxy is a tool for your own machine, and section 2.3 shows why it must not carry real traffic.
What must be up
Three things follow from the path.
- Postgres and
ate-api-serverare on every request path. A running actor that needs no resume still fails when either one is down. On our second guest, requests to a running actor failed within seconds of each outage. Section 8.2 has the timeline. - The Kubernetes API server is on no request path. Only row 2 of the figure passes it, and that row belongs to the proxy. In the same test, kube-apiserver was down for 86 s. In that time 103 of 103 requests from a pod were served: 86 to a running actor and 17 resumes.
- The object store is on the path only when a snapshot is not on the node. Section 3.4 says when that is.
The install runs one atenet-router pod and one Postgres pod. Every request to every actor passes both. Section 8.1 says what to do about Postgres.
The actor stays up
After the fifth request we sent nothing for 900 s. Figure 1.2b shows all six requests of the recording, and every state sample between the last two.
The sixth request took 0.080 s and returned count 6. A resume would have cost about 0.36 s. The actor was never suspended, and it held its worker for the whole 15 minutes. Section 1.1 names this as the first job that Substrate leaves to you.
Start
Install from the release images, create one actor, and prove that it answers before you change a setting.
Install with ate-setup
Install from the release images: that path skips the image builds, the Rust compile, and a failure that the upstream quickstart does not mention.
You follow a quickstart on a clean machine. The third command runs for almost three minutes and then stops with an error from Docker about a flag you never typed. We hit that error on our first install, and you do not need to.
When you finish this section, you can install Agent Substrate v1.0.0 on a kind cluster from the release images. You can also say what the build from source needs, and fix the install errors we hit.
What you need
- Linux or macOS with Docker. On Apple silicon we used Docker Desktop with 10 CPU and 8 GB.
- Go 1.27. The checkout asks for toolchain
go1.27.3, and Go downloads kind for you. gitandkubectl.- About 13 GB of disk. Our guest used 13 GB in all after this install, with the operating system.
Installer: ate-setup is the Go program in the checkout that installs and removes Agent Substrate. The release has no ate-setup binary, so you run it with go run from a checkout of the tag. The manifests also come from that checkout, so check out the tag that you install.
On Linux we raised two inotify limits before the install, because many pods on one node use many watches:
sudo sysctl -w fs.inotify.max_user_instances=8192 fs.inotify.max_user_watches=1048576Create the kind cluster
Clone the tag, then run the cluster script from the repository root:
git clone --branch v1.0.0 https://github.com/agent-substrate/substrate.git
cd substrate
hack/create-kind-cluster.shThe script does more than kind create cluster. Substrate needs three settings that a plain kind cluster does not have:
- It starts a local image registry named
kind-registryon127.0.0.1:5001. - It turns on three Kubernetes preview features:
ClusterTrustBundle,ClusterTrustBundleProjection, andPodCertificateRequest. Substrate gives every pod its certificate through them. - It looks for
/dev/kvm. With KVM, the node can run the microVM sandbox class. Without KVM, only gVisor works.
The script deletes an existing kind cluster with the same name before it creates the new one. The default name is kind. Set KIND_CLUSTER_NAME if you already have a cluster with that name.
Install the control plane from the release images
Tell ate-setup that the target is kind, then deploy:
export ATE_INSTALL_KIND=true
go run ./cmd/ate-setup deploy ate-system \
--credential-provider='{"name":"k8s.io"}' \
--ingress-allowed-spiffe-ids=spiffe://cluster.local/ns/ate-system/sa/atenet-client \
--image-repo ghcr.io/agent-substrate/substrate --image-tag v1.0.0Each flag has a reason:
ATE_INSTALL_KIND=trueselects the kind profile: the kind overlays, the local registry, and the object store in the cluster. Export it in every shell that runsate-setup.--credential-provideris required. The value{"name":"k8s.io"}deploys the bundled provider that reads Kubernetes Secrets. Section 6.5 uses it.--ingress-allowed-spiffe-idslists the clients that the router lets through. With no value, no client reaches any actor. The identity in this command is the one thatkubectl ate proxypresents.--image-repoand--image-taginstall published images. Each of the two needs the other.
The installer prints every setting first, and then one line for each step. These lines tell you that the images came from the registry and that the install is complete:
>> Downloading kata-static 4.3.1 (amd64)...
>> Downloading cloud-hypervisor v53.0 (cloud-hypervisor-static)...
…
[step]: images: pre-built from ghcr.io/agent-substrate/substrate, tag v1.0.0
…
[step]: Waiting for ATE system components to be ready...
(rollout status deployment/ate-api-server took 0.005s)
…
(rollout status daemonset/atelet took 0.003s)
(rollout status deployment/k8s-credential-provider took 0.003s)The first two lines show a download that the release images do not remove. The installer fetches the guest files of the microVM class from GitHub on the first install from a directory, and it keeps them under bin/microvm-assets. A later install from the same directory skips the download when the files and their version stamp are present. The installer documents --sandbox-classes=gvisor to install gVisor only. We ran that flag for one install, on a cluster with three kind nodes on the second x86 server guest (16 vCPU, 23 GB). The system step took 114 s.
Deploy the counter demo
The demo gives you a WorkerPool, a template, and an atespace to test with:
go run ./cmd/ate-setup deploy demo counterThis step builds two images with ko and pushes them to kind-registry, also on the release path. The release images leave out the demos. The step ends when the golden snapshot of the counter template is ready.
What we measured
Figure 2.1 shows the steps of both x86 installs.
We measured the release-image install once on a second x86 server guest (16 vCPU, 23 GB). The kind cluster took 55 s, deploy ate-system took 162 s, and deploy demo counter took 94 s. The disk held 13 GB afterwards. Raw runs
We measured the build from source once on the x86 server guest (24 vCPU, 47 GB, nested KVM). The first deploy ate-system failed after 163 s. After we installed one package, the same command took 266 s, and the demo took 47 s. The disk held 18 GB afterwards. Raw runs
We measured the release-image install once on an Apple silicon laptop under Docker Desktop. The kind cluster took 119 s, and deploy ate-system took 702 s. The longest wait in its log was 178 s for the object store pod. The kind node held 2.7 GiB of memory after the install. Raw runs
The two x86 installs ran on different guests, so the difference of 104 s between 162 s and 266 s is indicative only. It is not a controlled comparison. What we can state without a number is what each path does. The release-image path skips the image builds and the Rust compile, and the next heading shows both in the source log. The laptop is a third machine on another network, so keep its seconds apart from the seconds of the guests.
What the build from source costs
Leave out --image-repo and --image-tag, and ate-setup builds every image from the checkout. The upstream quickstart shows that form. It lists Go, kubectl, and Docker as the things you need. On Ubuntu 24.04 with the stock Docker package, that list is one item short:
unknown flag: --platform
…
Error: while building the envoy-dataplane image: exit status 125The Envoy dataplane image is built from a Dockerfile with docker buildx. The other images are built with ko. On Ubuntu the fix is one package:
sudo apt-get install -y docker-buildxThe second cost is a Rust compile. The Envoy image contains a Rust module for the egress policy. The build clones the Envoy repository and compiles that module:
#11 0.323 Updating git repository `https://github.com/envoyproxy/envoy`
…
#11 53.04 Compiling proc-macro2 v1.0.107
…
#11 91.89 Finished `release` profile [optimized] target(s) in 1m 31s
…
#11 DONE 92.2sThat one build step took 92 s of the 266 s. You pay it again when the build cache is empty. Build from source when you change Substrate itself. For every other install, use the release images.
Change the dataplane later
The same command switches a running cluster from Envoy to agentgateway. Add one flag and run it again:
go run ./cmd/ate-setup deploy ate-system \
--credential-provider='{"name":"k8s.io"}' \
--ingress-allowed-spiffe-ids=spiffe://cluster.local/ns/ate-system/sa/atenet-client \
--image-repo ghcr.io/agent-substrate/substrate --image-tag v1.0.0 \
--atenet-dataplane=agentgatewayWe measured this switch once on the second x86 server guest (16 vCPU, 23 GB). It took 32 s and needed no new cluster. Section 6.6 compares the two dataplanes. Raw runs
If the install fails
| What you see | Cause | Fix |
|---|---|---|
unknown flag: --platform, then exit status 125 | A build from source without the Docker buildx plugin | Install the plugin, or install from the release images |
The installer stops and prints retry with: ate-setup deploy ate-system --config and a file | A step failed, and the installer saved the settings of the run | Fix the cause, then run the printed command. Start it with go run ./cmd/ate-setup, because the release has no ate-setup binary |
kubectl shows pods you do not know | The kubeconfig context is not the one of your kind cluster | Run kubectl config use-context kind-kind. With your own KIND_CLUSTER_NAME, the context is kind- and that name |
The last row happened to us on the laptop. After a restart of Docker Desktop, kubectl pointed at the cluster that Docker Desktop includes, and our first test ran there.
To remove everything, delete the cluster and the registry:
./hack/delete-kind-cluster.shOld guides for v0.x use install scripts that no longer exist. Section 8.4 lists what changed.
kubectl ate
kubectl ate talks to ate-api-server and reads Postgres, and without --token-file every caller acts as one shared identity.
You type kubectl get actors on a cluster that runs Agent Substrate, and Kubernetes answers that it has no such resource. The actor exists. You asked the wrong server. Actors, templates, and workers are rows in the Substrate database, and one plugin reads them.
When you finish this section, you can install kubectl ate and say how each command reaches the control plane. You can also explain the warning that it prints on every call.
Install the plugin
Plugin: kubectl-ate is one binary. When it is on your PATH, kubectl runs it for every command that starts with kubectl ate.
The release publishes the binary for Linux and macOS, on amd64 and arm64. There is no Windows build. Download the file and its checksum, check it, and install it:
BASE=https://github.com/agent-substrate/substrate/releases/download/v1.0.0
curl -LO $BASE/kubectl-ate-linux-amd64
curl -LO $BASE/kubectl-ate-linux-amd64.sha256
shasum -a 256 -c kubectl-ate-linux-amd64.sha256
mkdir -p ~/.local/bin
install -m 0755 kubectl-ate-linux-amd64 ~/.local/bin/kubectl-ateOn a Mac with Apple silicon, the file is kubectl-ate-darwin-arm64. If you have the checkout and Go, you can build the plugin from the tag:
go install ./cmd/kubectl-atekubectl finds the plugin only on your PATH. Check that ~/.local/bin is on it, or the directory $(go env GOPATH)/bin when you use go install.
We measured the build once on an x86 server guest with 16 vCPU and 23 GB. go install took 5 s after the install of the control plane, because the Go modules were already on disk. In our runs we always built the plugin from the tag. We checked that both release files exist, and we did not time the download. Raw runs
Check the version before you go on:
kubectl ate --versionHow a command reaches the control plane
kubectl ate does not ask the Kubernetes API for actors. It uses your kubeconfig to open a port-forward to the Service api in the namespace ate-system. Without --token-file, it also uses the kubeconfig to request a token for the shared ate-client ServiceAccount. Then it sends a gRPC request through that port-forward to ate-api-server, and ate-api-server answers from Postgres. Figure 2.2 shows that path beside the path of a plain kubectl command.
Two facts follow from the figure.
First, each command opens its own port-forward. So kubectl ate needs a working Kubernetes API server, and your account needs the right to port-forward into ate-system. With kube-apiserver down, a command fails like this:
Error: failed to connect to ate-api-server: getting api service: Get "https://127.0.0.1:44015/api/v1/namespaces/ate-system/services/api": EOFActors kept serving requests during that outage. Only the CLI and the proxy failed. Section 2.3 has the numbers.
Second, the flag --endpoint skips the port-forward. A program inside the cluster passes the address of ate-api-server and dials it directly. Its pod must be in a namespace with the label ate.dev/api-access=true. We did not set that label ourselves, and we did see the network rule behind it in the kagent run. A pod in default could not reach the API, and a pod in kagent could. Section 5.1 has that probe.
The shared identity warning
Every command in our recordings starts with the same line:
Warning: no --token-file given; acting as the shared identity system:serviceaccount:ate-system:ate-client rather than as yourself. Pass --token-file to act as yourself.
ATESPACE NAME TEMPLATE STATE WORKER POD WORKER IPS VERSION AGE
ate-demo-counter my-counter-1 ate-demo-counter/counter ACTOR_STATE_SUSPENDED <none> 1 0sShared identity: the ServiceAccount ate-client in ate-system. When you pass no token, the plugin asks Kubernetes for a token of that ServiceAccount and sends it to ate-api-server.
The warning is correct, and you must not ignore it on a shared cluster. Every person and every script that can port-forward into ate-system is the same caller to Substrate. An access policy cannot tell them apart, because it sees one name.
On a kind cluster on your own machine, the shared identity is fine. On a cluster with more than one team, give each caller a token and pass it with --token-file. Section 5.1 shows which token to use, and section 5.2 limits what a caller can do in an atespace.
Our scripts removed the line from their output with grep -v '^Warning: no --token-file'. Do that in a script that parses a table. Do not do it for people.
The commands of the first day
An actor is addressed by its atespace and its name, so most commands need -a with an atespace. No default atespace exists.
| Command | What it does |
|---|---|
kubectl ate get actors -a ate-demo-counter | lists the actors of one atespace |
kubectl ate get actors -A | lists the actors of all atespaces |
kubectl ate get actor my-counter-1 -a ate-demo-counter -o yaml | prints one actor with its snapshots |
kubectl ate create actor my-counter-1 -a ate-demo-counter --template counter | creates an actor from a template |
kubectl ate suspend actor my-counter-1 -a ate-demo-counter | writes a snapshot and frees the worker |
kubectl ate resume actor my-counter-1 -a ate-demo-counter | restores the actor onto a worker |
kubectl ate revert actor my-counter-1 -a ate-demo-counter | returns a crashed actor to its last durable snapshot |
kubectl ate logs actor my-counter-1 -a ate-demo-counter | prints the logs of the actor on its worker |
kubectl ate get workers | lists the workers with their used CPU and memory |
kubectl ate proxy actor my-counter-1 -a ate-demo-counter | forwards a local port to the actor |
-o json and -o yaml print the full object. Use them when you debug, because the table hides the snapshots. This is the new actor as JSON:
"status": {
"lastAssignedGeneration": 1,
"snapshots": [
{
…
"durableSnapshot": {
…
"state": "SNAPSHOT_STORAGE_STATE_COMPLETED"
},
"fidelity": "SNAPSHOT_FIDELITY_MEMORY",
"generation": 1,
"owner": "SNAPSHOT_OWNER_TAG",
…
"state": "ACTOR_STATE_SUSPENDED"The actor has never run, and it already has one snapshot. The owner is a tag, and the tag belongs to the template. Section 3.1 explains that snapshot. The command reference lists every command and flag.
Three mistakes we made
-A on workers. A worker belongs to a Kubernetes namespace and not to an atespace. The flag that works for actors fails for workers:
Error: unknown flag "-A"
…
-a, --atespace string filter worker pods hosting actors in a specific atespace
…
-n, --namespace string scope output to a specific Kubernetes namespaceRun kubectl ate get workers with no flag to see all workers.
kubectl get for an actor. kubectl get actor and kubectl get actortemplate return nothing, because those objects are not custom resources. kubectl get workerpool does work, because a WorkerPool is one.
A flag from an old guide. Guides for v0.x create an actor with --template-ref. At v1.0.0 the flag is --template. Section 8.4 lists the other changes.
Your first actor and kubectl ate proxy
kubectl ate proxy is a tool for your own machine: it tunnels through the Kubernetes API server, so it fails when that server is down while actor traffic continues.
The install is done, and you want to see an actor answer. The quickstart gives you two commands and a curl. They work. Then you put the same proxy command in a test script. The control plane of the cluster restarts, and every request times out while the actors are fine.
When you finish this section, you can create an actor, reach it through kubectl ate proxy, and suspend it. You can also say why the proxy must not carry real traffic, and what a real client needs in its place.
Create the actor
The counter demo created an atespace named ate-demo-counter and a template named counter in it. Create an actor from that template:
kubectl ate create actor my-counter-1 -a ate-demo-counter --template counterThe command prints the new actor. In our run the state was ACTOR_STATE_SUSPENDED, the worker pod was <none>, and the version was 1. Section 2.2 shows that output.
A new actor uses no worker. Nothing was started. The create call wrote one row to Postgres and pointed the actor at the golden snapshot of its template. In one of our tests we created 131 actors on a pool of three workers before any of them ran.
Start the proxy
Start the proxy in a terminal of its own. It stays in the foreground:
kubectl ate proxy actor my-counter-1 -a ate-demo-counterProxying 127.0.0.1:8000 -> ate-demo-counter/my-counter-1:80With no port argument, the proxy forwards local port 8000 to port 80 of the actor. To choose the ports, add pairs in the form local:remote, such as 8080:80. The proxy listens on 127.0.0.1 only, unless you pass --address.
Proxy: kubectl ate proxy actor is a local listener that forwards TCP to one actor through atenet-router. It does three things for you. It opens a port-forward to the router. It gets a client certificate for the identity atenet-client. It names the target actor to the router.
The install must allow that identity. This is why the install command in section 2.1 passes --ingress-allowed-spiffe-ids with the atenet-client name. Without that flag, the router lets no client through, and the proxy cannot reach any actor.
Send requests
In a second terminal, send a request:
curl -X POST http://localhost:8000/Send it a few times. This is our recording of the first three requests:
2026-11-12T10:14:21.837Z call-1 200 0.358379 preserved memory count: 1 | preserved file counter: 1 | preserved rootfs counter: 1
2026-11-12T10:14:21.944Z call-2 200 0.090770 preserved memory count: 2 | preserved file counter: 2 | preserved rootfs counter: 2
2026-11-12T10:14:22.030Z call-3 200 0.066903 preserved memory count: 3 | preserved file counter: 3 | preserved rootfs counter: 3The counter demo keeps three counts: one in memory, one in a file, and one in the root filesystem. All three rise together here. They matter later, when a snapshot keeps less than the memory. Section 3.4 uses them.
The first request resumed the actor. Now it holds a worker:
NAME POOL STATE ACTORS CPU(MILLICORES) MEMORY POD AGE
ec15263e-4c0f-45eb-adc1-53f5f60c2331 counter WORKER_STATE_ACTIVE 1/1000 1000/1000 512Mi/960Mi ate-demo-counter/counter-7cc7dcd596-ffxwt 74sRead the CPU(MILLICORES) column. This one actor takes 1000 of the 1000 millicores of its worker. The actor limit of 1000 in the ACTORS column is not the limit that binds. The CPU is. So the demo pool of three workers runs three counter actors at one time, and a fourth must wait. Section 4.5 sizes a pool from these columns.
Suspend it yourself
Stop sending requests and watch the actor. It stays in ACTOR_STATE_RUNNING. Substrate does not suspend it.
We measured this on an x86 server guest with 24 vCPU and 47 GB, in one run. For 900 s we sent no request and read the state every 15 s. All 60 samples were ACTOR_STATE_RUNNING. The next request took 0.080 s and returned count 6. Raw runs
To free the worker, suspend the actor:
kubectl ate suspend actor my-counter-1 -a ate-demo-counterWe measured the suspend on the same guest, once. The command returned in 0.40 s, and the state was ACTOR_STATE_SUSPENDED. The next request through the proxy took 0.36 s and returned the next count, so the memory came back. Raw runs
This default suspend is asynchronous. The new snapshot is on the node and is not yet in the object store. Section 3.3 explains the two modes, and section 3.5 shows what you lose when the node copy disappears.
The proxy depends on the Kubernetes API server
The proxy reaches the router through a Kubernetes port-forward. Every byte passes kube-apiserver. A client inside the cluster does not need that hop. Figure 2.3 puts the two paths side by side.
We measured both paths on an x86 server guest with 16 vCPU and 23 GB, in one run with one request each second. We took kube-apiserver away for 86 s. Through the proxy, 0 of 81 requests in that time were served, and each one ran into the 9 s limit of the client. From the pod, 86 of 86 were served. The proxy kept failing for 21 s after kube-apiserver was ready again. Raw runs
== T3 kube-apiserver: down at +0 s, restore action at +86 s, dependency ready at +91 s
proxy/running (VM, kubectl ate proxy)
codes from -10 s: 200x9 000x105 200x59
first failure at +0 s, last failure at +112 s (= +26 s from restore action, +21 s from dependency ready); failed 105 of 173
failure latency p50 9.00 s max 9.00 s
105x code=000 body=''
incluster/running (pod, mTLS to router)
codes from -10 s: 200x186
no failuresSo do not judge the health of Substrate with the proxy, and do not put the proxy between a product and its actors. The proxy tells you about your path to the cluster. A pod that calls the router tells you about Substrate.
What a real client needs
A real client is a pod. It needs three things, and the pod we used for the test shows all of them.
kind: Pod
…
metadata: {name: e-prober, namespace: ate-system, labels: {app: e-prober}}
…
serviceAccountName: atenet-client
…
- podCertificate: {credentialBundlePath: credential-bundle.pem, keyType: ECDSAP256, signerName: podidentity.podcert.ate.dev/identity}
…
- clusterTrustBundle: {labelSelector: {matchLabels: {podcert.ate.dev/canarying: live}}, path: trust-bundle.pem, signerName: servicedns.podcert.ate.dev/identity}- An allowed identity. The pod runs as the ServiceAccount
atenet-clientinate-system. That is the identity in--ingress-allowed-spiffe-ids. The install does not create this ServiceAccount, so we created it. - A client certificate. The
podCertificatevolume asks Kubernetes for a certificate with that identity. TheclusterTrustBundlevolume holds the CA that verifies the router. - The target header. Each request names its actor in the header
ate-target-actor, as the atespace, a slash, and the actor name.
The request of that pod was this command:
curl --cacert /run/ca/trust-bundle.pem \
--cert /run/id/credential-bundle.pem --key /run/id/credential-bundle.pem \
-X POST -H 'ate-target-actor: lab-e/e-run' \
https://atenet-router.ate-system.svc:443/Any listed client can reach any actor through the router. The allowlist says who can enter. It does not say which actor each client can call. Section 5.1 and section 6.1 cover the identity and the router.
What the install created
The system install created 15 running pods in three namespaces and three custom resources, and it put every atespace, template, actor, and worker in Postgres, where kubectl cannot see them.
The install ended with no error, and kubectl get pods -A now prints a page of names you did not choose. One of them will be in an error message next week. You want to know what each pod does, which ones have a second replica, and where your actors are stored.
When you finish this section, you can name the job of each pod that the install created. You can also say which objects live in etcd and which live in Postgres, and which pods run as a single replica.
The pods
This is the namespace ate-system after our install from source. The install from the release images created the same pods.
NAMESPACE NAME READY STATUS RESTARTS AGE …
ate-system ate-api-server-696d95dd66-9q7f8 2/2 Running 0 117s …
ate-system ate-api-server-696d95dd66-t85dd 2/2 Running 0 117s …
ate-system ate-controller-7479ff54f-jrrtc 1/1 Running 0 114s …
ate-system ate-idp-server-7c46c85d94-66sf2 1/1 Running 0 116s …
ate-system ate-idp-server-7c46c85d94-cfbt4 1/1 Running 0 116s …
ate-system atelet-sqzvr 2/2 Running 0 113s …
ate-system atenet-egress-78f759fcb8-74d4m 3/3 Running 0 76s …
ate-system atenet-router-86454b57f5-7vtp5 2/2 Running 0 112s …
ate-system k8s-credential-provider-8df9859f8-jcfll 1/1 Running 0 89s …
ate-system postgres-0 2/2 Running 0 7m13s …
ate-system rustfs-54c86945fb-w9pnn 1/1 Running 0 109s …
ate-system rustfs-bucket-init-bvq6z 0/1 Completed 0 109s …Figure 2.4 groups all the pods by namespace and gives each one the colour of its role.
Each workload has one job:
| Workload | Pods | Job |
|---|---|---|
ate-api-server | 2 | The API and the scheduler. It serves kubectl ate and the router, and it keeps all its state in Postgres |
ate-controller | 1 | Turns each WorkerPool into a Deployment of worker pods, and registers each worker pod with ate-api-server |
ate-idp-server | 2 | Publishes the keys that verify the JWT of an actor. Section 5.3 uses it |
atelet | 1 on each node | The node agent. It pulls images, restores and checkpoints sandboxes, keeps snapshots on the node, and uploads them |
atenet-router | 1 | Ingress. It checks the client certificate, asks ate-api-server where the actor is, and forwards the request |
atenet-egress | 1 | Egress. Every outbound connection of every actor goes through it |
k8s-credential-provider | 1 | Reads Kubernetes Secrets for credential injection |
postgres-0 | 1 | The database of Substrate |
rustfs | 1 | The object store for snapshots on kind. A cloud install uses a bucket |
rustfs-bucket-init | a finished Job | Created the bucket ate-snapshots once |
One more pod runs in its own namespace. podcertificate-controller signs the certificates that the pods use to prove who they are. It needs the three Kubernetes preview features that the cluster script turned on.
What has one replica
Count the pods in the listing. ate-api-server and ate-idp-server have two. Everything else has one.
That matters for three of them. Every request to every actor passes atenet-router, and ate-api-server reads Postgres for each one. Section 1.2 shows the path. So one router pod and one Postgres pod are in front of all your actors. The bundled Postgres has no second replica and no failover.
For a kind cluster on your machine, that is fine. For anything that people depend on, read section 8.1 before you go further. It covers an external database and what a Postgres outage does to requests.
Three custom resources
The install added three custom resources to Kubernetes:
NAME SCOPE VERSIONS CREATED AT
csidriverconfigs.ate.dev Cluster v1(storage) 2026-11-12T10:05:33Z
sandboxconfigs.ate.dev Cluster v1(storage) 2026-11-12T10:05:33Z
workerpools.ate.dev Namespaced v1(storage) 2026-11-12T10:05:33ZWorkerPool: a namespaced object that asks for a number of worker pods of one sandbox class. It is the only Substrate object that you manage with plain kubectl day to day. Section 4.1 covers it.
SandboxConfig describes a sandbox class for the whole cluster. The install created two of them: gvisor-default and microvm. CSIDriverConfig belongs to a preview feature for volumes, and we did not use it.
Everything else is a row in Postgres: atespaces, templates, actors, tags, workers, egress policies, and access policies. So kubectl get does not list them, a backup of etcd does not hold them, and no Kubernetes RBAC rule names them. To back up your actors, back up Postgres and the object store. A snapshot from a default suspend reaches the object store about 5 minutes later, so an earlier backup does not hold it. Section 3.4 has the timers. Section 3.6 and section 5.2 cover names and access.
The demo namespace
deploy demo counter created the namespace ate-demo-counter with one WorkerPool named counter. ate-controller turned it into three worker pods. Each pod runs the image ateworker-gvisor, which holds the gVisor sandbox.
The demo also created two things that kubectl cannot show: the atespace ate-demo-counter and the template counter. Read them with the plugin:
kubectl get workerpools -A
kubectl ate get atespaces
kubectl ate get actor-templates -a ate-demo-counterThe atespace and the Kubernetes namespace have the same name here. They are two objects. An atespace is a Substrate name, and nothing ties it to a namespace.
A worker pod is not an ordinary pod. It runs as root with the SYS_ADMIN and NET_ADMIN capabilities, because it starts sandboxes. Put WorkerPools on nodes that you trust for that. Section 4.2 covers the sandbox classes.
The telemetry namespace
The kind profile also installs otel-system with three pods: an OpenTelemetry collector, Prometheus, and Jaeger. They are not part of Substrate. They receive its metrics and traces.
They are useful on day one. Add --trace to any kubectl ate command, forward the Jaeger port, and open the trace:
kubectl port-forward -n otel-system svc/jaeger 16686:16686 &
kubectl ate get actor my-counter-1 -a ate-demo-counter --traceWhat it costs on one machine
We measured the size of the release-image install once on an x86 server guest with 16 vCPU and 23 GB. Docker held 2.1 GB of images and 4.8 GB of volumes. The guest used 13 GB of disk in all, with the operating system and the Go caches. Raw runs
We measured the memory once on an Apple silicon laptop under Docker Desktop. The kind node held 2.7 GiB right after the install, with no actor. Raw runs
The install also left two things outside the cluster. The first is a second container, the local registry:
kind-control-plane kindest/node:v1.37.0
kind-registry registry:3The second is a file. ate-setup records the settings of each install in the cache directory of your user, one file for each kubeconfig context. That is ~/.cache/ate-setup/installs/ on Linux and ~/Library/Caches/ate-setup/installs/ on macOS, and the flag --record-dir names another place. Keep that file. It tells you later which flags the running cluster was installed with.
Actors
An actor is a snapshot that runs for a while, so its speed and its safety both depend on where that snapshot is.
ActorTemplate and the golden snapshot
Substrate snapshots each ActorTemplate once, and an actor created after that starts suspended from the snapshot, so its first call pays a restore.
Your agent image takes several seconds to import its libraries and load its tools. With a Deployment, every new pod pays those seconds again. Substrate moves that cost to the template. It starts the image once, takes a snapshot, and creates every new actor from that snapshot. The cost does not disappear: the first request to a new actor restores the snapshot, and the restore grows with the memory in it.
When you finish this section, you can write an ActorTemplate, check that its golden snapshot exists, and predict the first-call time of a new actor.
The template we used
ActorTemplate: the description of an actor: its containers, its resource limits, its volumes, the worker pools that it can run on, and its snapshot settings. A template belongs to one atespace, and ate-api-server stores it in Postgres. It is not a Kubernetes object, so kubectl get does not list it.
This is one of the five templates of our resume tests. The actor is a small Go server that holds 200 MiB of random bytes and counts its requests.
metadata:
atespace: lab-c
name: ballast-200
workerSelector:
matchLabels:
workload: ballast
containers:
- name: ballast
image: localhost:5001/ballast@sha256:93dba82ad560e7fc4ccb2a33a63d64713b519a7895ba3fd98130ce789eab7fd3
command:
- /ballast
- --mb=200
wakeupProbe:
httpGet:
path: /readyz
port: 80
resources:
limits:
cpuMillicores: 1000
memoryBytes: 478150656
snapshotConfig:
preferredFidelity: SNAPSHOT_FIDELITY_MEMORYFive fields decide how the actor behaves.
imagemust name a digest, and a tag alone is refused. Ours is ascratchimage from a plaindocker build, with no change for Substrate.resources.limitsneeds both values. The scheduler books them against the capacity of a worker, which section 4.1 explains.workerSelectormatches the labels of a WorkerPool object. Section 3.6 shows why the namespace of the pool does not count.wakeupProbeis a readiness check that runs on every resume. The actor becomesACTOR_STATE_RUNNINGonly after the probe passes.preferredFidelitysays how much state a snapshot keeps. Section 3.4 compares the three levels.
A template is immutable, and the API has no call that updates one. For new code you create a new template, and then you move each suspended actor with kubectl ate update actor --template.
We ran that move with two templates that differ in the image. On a running actor the update is refused.
…Error: failed to update actor: rpc error: code = FailedPrecondition desc = actor must be ACTOR_STATE_SUSPENDED to change its actor template (got: ACTOR_STATE_RUNNING) On the suspended actor the update was accepted, and the next call ran the new image. The count in memory started again at 1, and so did the count on the root filesystem. The count on the durableDir volume continued at 4. So this move kept the durableDir volume and dropped the old memory snapshot. We did not test a move with an external volume. Suspend first, and expect a cold start of the new image on the next call.
The golden snapshot is built once
Golden snapshot: the snapshot of one temporary actor that Substrate starts from a new template. Substrate stores it under a tag, the golden tag, and every new actor of that template starts from that tag.
ate-api-server builds the golden snapshot in four steps.
- It creates a temporary golden actor in the atespace of the template, and it resumes that actor.
- It waits until the actor is ready. If a container has no
wakeupProbe, it waits 20 seconds more. - It suspends the actor at the
preferredFidelityof the template, and it copies the snapshot to a published tag. - It deletes the golden actor, and it writes the tag into the status of the template.
The log of ate-api-server on our kagent cluster shows all four steps for one template.
2026-11-12T10:16:44.165402326Z dd7b8625-0d96-4d7d-89da-4ec9cc3b914a create suspended
2026-11-12T10:16:44.178659651Z dd7b8625-0d96-4d7d-89da-4ec9cc3b914a resume resuming
2026-11-12T10:16:54.017864285Z dd7b8625-0d96-4d7d-89da-4ec9cc3b914a resume running
2026-11-12T10:16:54.03004179Z dd7b8625-0d96-4d7d-89da-4ec9cc3b914a suspend suspending
2026-11-12T10:16:54.180722259Z dd7b8625-0d96-4d7d-89da-4ec9cc3b914a suspend suspended
2026-11-12T10:16:54.238242923Z dd7b8625-0d96-4d7d-89da-4ec9cc3b914a delete deleting
2026-11-12T10:16:54.328177186Z dd7b8625-0d96-4d7d-89da-4ec9cc3b914a delete deletedThe timestamps show a resume of 9.84 seconds, because the golden actor is the one actor that boots the image from zero. The suspend took 0.15 seconds. That template had a files-only fidelity, so the snapshot held no memory.
The build pays the full start of your application, once.
We measured one template for a Python server on the x86 server guest (24 vCPU, 47 GB, nested KVM), with n=1. The server builds a dict of one million values at start. The golden tag was ready 39.3 seconds after create actor-template. The process was 32.9 seconds old at its first request, so most of that time was the application start under gVisor. A new actor of that template then answered its first call in a median of 0.63 seconds, with five new actors. Section 3.3 shows the server. Raw runs
The golden tag does not exist at the moment the template does. In our resume tests we listed the five templates 21 seconds after we created them.
ATESPACE NAME GOLDEN TAG ERROR AGE
lab-c ballast-0 e32029d7-3d9b-4212-8953-e88d7cede5b4 21s
lab-c ballast-1000 20s
lab-c ballast-200 b9f97b82-b187-419d-8789-6c8c7b8a6c8c 20s
lab-c ballast-50 0382e2fc-99db-461a-8845-a90d93484443 21s
lab-c ballast-500 2fa7aaf9-1fe8-4076-9350-af27ba338a6b 20sAll five had a tag when we looked again at 118 seconds. Wait for the GOLDEN TAG column before you create actors. An actor that you create earlier records no snapshot. It boots from the image on its first call, and it keeps that cold start until its own first suspend.
We ran that race. We created an actor directly after its template, and create actor succeeded.
2026-11-12T12:49:47.365Z rc1-state ACTOR_STATE_SUSPENDED -
2026-11-12T12:49:48.542Z rc1-first-call 200 0.983729 tag=v3 count=1 mb=200 fill=random sum=26936 rootfs=-1 data=-1 pid=1 process_age_s=0.8 child[none]
…
2026-11-12T12:50:00.900Z golden-ready waited 11.137 s more
2026-11-12T12:50:01.888Z rc2-state-new ACTOR_STATE_SUSPENDED - SNAPSHOT_OWNER_TAG:SNAPSHOT_FIDELITY_MEMORY:SNAPSHOT_STORAGE_STATE_COMPLETED:gen1We measured this once on the x86 server guest (24 vCPU, 47 GB, nested KVM), with a 200 MiB actor. The early actor was ACTOR_STATE_SUSPENDED with an empty snapshot list. Its first call was a cold start and took 0.98 seconds. The golden tag was ready 11 seconds later, and the first call of an actor created after that took 0.93 seconds. Five more early actors of another template still had no snapshot 25 seconds later. Raw runs
So an early actor never adopts the golden snapshot. It gets its own snapshot at its first suspend. Our actor starts in under a second, so the two first calls cost the same. With an application that needs 30 seconds to start, the early actor pays 30 seconds on its first call.
You can turn the golden snapshot off with snapshotConfig.goldenSnapshotConfig.mode: GOLDEN_SNAPSHOT_MODE_DISABLED. Every new actor then boots from the image.
A new actor starts suspended
kubectl ate create actor writes one row in Postgres and touches no worker, so its time does not depend on the template. The new actor is suspended. It already has a snapshot when the template had its golden tag at that moment.
{
"actorTemplate": {
"atespace": "ate-demo-counter",
"name": "counter"
},
"metadata": {
"atespace": "ate-demo-counter",
"createTime": "2026-11-12T10:13:40.987568669Z",
"name": "my-counter-1",
"uid": "6f80f612-7547-4f5b-b3ab-831595621806",
"updateTime": "2026-11-12T10:13:40.987568669Z",
"version": "1"
},
"status": {
"lastAssignedGeneration": 1,
"snapshots": [
{
"actorTemplateUid": "71a3a39c-5f37-4d6e-937f-9c314fea7668",
"durableSnapshot": {
"object": {
"snapshotUri": "gs://ate-snapshots/atespaces/ate-demo-counter/tags/ad19ee28-1519-432a-bcc5-40cf99cce3ca"
},
"state": "SNAPSHOT_STORAGE_STATE_COMPLETED"
},
"fidelity": "SNAPSHOT_FIDELITY_MEMORY",
"generation": 1,
"owner": "SNAPSHOT_OWNER_TAG",
…
"state": "ACTOR_STATE_SUSPENDED"
}
}Read three fields. state is ACTOR_STATE_SUSPENDED, so no sandbox exists yet. owner is SNAPSHOT_OWNER_TAG, so the actor borrows the snapshot of the golden tag and owns none. The URI has a tags segment, and it does not hold the uid of the actor. The actor writes its own snapshot at its first suspend.
The first call pays for the restore
The first request to a new actor restores the golden snapshot. For a small actor you do not notice it. For a large one, it is the slowest request that the actor ever serves.
| Memory in the template | First call of a new actor, median | Fastest and slowest of 5 | Later resume of the same actor, median |
|---|---|---|---|
| 0 MiB | 0.23 s | 0.22 to 0.24 s | 0.22 s |
| 50 MiB | 0.25 s | 0.24 to 0.40 s | 0.24 s |
| 200 MiB | 0.62 s | 0.56 to 0.86 s | 0.47 s |
| 500 MiB | 5.76 s | 1.86 to 6.76 s | 1.07 s |
| 1,000 MiB | 6.60 s | 3.46 to 12.75 s | 2.16 s |
We measured these times on the x86 server guest (24 vCPU, 47 GB, nested KVM), with gVisor and five new actors for each size. Each time is one request through kubectl ate proxy, timed at the client. A request to an actor that already runs took 0.06 seconds on the same path. The later resume is the median of 10 resumes from the snapshot on the node. kubectl ate create actor returned in 0.20 to 0.26 seconds for all 25 actors. Raw runs
Up to 200 MiB the first call costs about the same as a later resume. At 500 MiB and above it costs three to five times more, and the spread is wide. We did not find the cause of that spread. We can say where the node kept data. Its snapshot-cache directory held 1,765 MiB, and the five snapshot sizes of our templates add up to 1,760 MiB.
Our position: the golden snapshot is a good place for slow start code and a bad place for large memory. If your template holds more than about 200 MiB, send one request to each new actor before a user does. Use a request that changes no state: our counter adds one on every call. Then suspend it. A later resume from the node copy follows the lower line.
Actor and its seven states
An actor is always in one of seven states, and that state decides what a request, a resume, and a suspend do.
A request to your actor returns 503. kubectl get pods shows every pod as Running, and the worker pod log does not name your actor. Kubernetes cannot tell you more, because an actor is not a pod. Its state is a row in Postgres, and kubectl ate get actors reads that row.
When you finish this section, you can read the state of an actor and name the call that moves it. You can also predict what a request does in each state.
The Actor object
Actor: one instance of an ActorTemplate, with a name that is unique in its atespace. An actor is the unit that Substrate suspends and resumes. It can run on a different worker after each resume.
This is the counter actor of the quickstart while it runs.
actorTemplate:
atespace: ate-demo-counter
name: counter
metadata:
atespace: ate-demo-counter
createTime: "2026-11-12T10:13:40.987568669Z"
name: my-counter-1
uid: 6f80f612-7547-4f5b-b3ab-831595621806
updateTime: "2026-11-12T10:30:18.103768100Z"
version: "16"
status:
assignedNode: kind-control-plane
lastAssignedGeneration: 3
snapshots:
…
state: ACTOR_STATE_RUNNING
workerAssignment:
nodeName: kind-control-plane
…
workerPod: counter-7cc7dcd596-fps97
…
workerPodUid: c572e96d-dd9b-4500-b0e3-880a98d9f61d
workerPool: counter| Field | What it tells you |
|---|---|
metadata.version | A counter that rises on every change of the actor. This actor reached 16 in 17 minutes. |
status.state | One of the seven states. |
status.workerAssignment | The worker pod that hosts the sandbox. It is set only while a worker holds the actor. |
status.assignedNode | The node of the worker while the actor runs, and the node that keeps the newest snapshot on its disk after a suspend. |
status.snapshots | The snapshots that the actor can resume from, at most 16. |
status.crash | A time and a message. It is set when the actor enters ACTOR_STATE_CRASHED, and a revert clears it. |
workerAssignment and assignedNode answer different questions. The first says where the actor runs now. The second also says where its last snapshot is after a suspend, and section 3.4 shows how long that lasts.
The seven states
The enum ActorState has seven values at v1.0.0. The state diagram on the architecture page of the project draws four of them, so trust the enum.
| State | What the actor has | What a request does |
|---|---|---|
ACTOR_STATE_SUSPENDED | No sandbox, and zero or more snapshots | The router resumes the actor, then the actor answers |
ACTOR_STATE_RESUMING | A worker, and a restore that is not complete | It waits for the same resume |
ACTOR_STATE_RUNNING | A sandbox on a worker | The actor answers |
ACTOR_STATE_SUSPENDING | A worker, and a checkpoint that is not complete | A request that is open when the suspend starts gets a 503. We did not test a request that arrives in this state |
ACTOR_STATE_CRASHED | No sandbox, and a crash record | 503 at once, with the state in the text |
ACTOR_STATE_REVERTING | A revert that is not complete | 503 at once, with the state in the text |
ACTOR_STATE_DELETING | A delete that is not complete | 503 at once, with the state in the text |
Three of these states are stable. ACTOR_STATE_SUSPENDED lasts until a request or a resume call arrives. ACTOR_STATE_CRASHED lasts until a revert or a delete, and a request does not end it. ACTOR_STATE_RUNNING lasts until a caller suspends the actor or Substrate marks it crashed. Section 3.3 proves that Substrate does not suspend it. The other four states are steps of one operation.
ACTOR_STATE_RUNNING does not prove that the process is alive. Two actors whose process had exited read ACTOR_STATE_RUNNING for the 20 minutes of our watch. Section 3.5 shows that run.
Old tutorials name a state ACTOR_STATE_PAUSED and a command kubectl ate pause actor. Both are gone at v1.0.0.
The transitions we saw
We drove my-counter-1 through a suspend, a resume, a crash, and a revert in 13 seconds. A script read the state after each step.
2026-11-12T10:30:09.378Z state ACTOR_STATE_SUSPENDED kind-control-plane 9 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen3
2026-11-12T10:30:10.310Z state ACTOR_STATE_RUNNING kind-control-plane 11 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen3
…
2026-11-12T10:30:11.590Z state-after-crash ACTOR_STATE_CRASHED kind-control-plane 12 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen3
…
2026-11-12T10:30:17.724Z state ACTOR_STATE_SUSPENDED - 14 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen3
…
2026-11-12T10:30:18.333Z state ACTOR_STATE_RUNNING kind-control-plane 16 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen3Look at the version column. A resume moved it from 9 to 11, and the revert moved it from 12 to 14. Each operation writes the actor two times: one write for the step state, and one for the stable state. Our script read the state after each command returned, so it never saw the state between.
The log of ate-api-server does show those states. The listing in section 3.1 has resuming before running, suspending before suspended, and deleting before deleted.
We measured the step states in the kagent run, on a second x86 server guest (16 vCPU, 31 GB). There a harness resumes and suspends an actor for every turn. Over 98 turns, the actor spent a median of 0.38 seconds in ACTOR_STATE_RESUMING. Over 99 suspends, it spent a median of 0.14 seconds in ACTOR_STATE_SUSPENDING. Those actors keep files only, so the times are low. Raw runs
A step state can also last. In our dependency test, one actor was still in ACTOR_STATE_RESUMING five minutes after Postgres came back. Section 8.2 has that case.
Who moves an actor
| Call | Accepted from | Result | Who calls it |
|---|---|---|---|
CreateActor | no actor | SUSPENDED | You, or your harness |
ResumeActor | SUSPENDED, RESUMING (the call continues the resume), and RUNNING (no change) | RESUMING, then RUNNING | The router for every request, or kubectl ate resume actor |
SuspendActor | RUNNING | SUSPENDING, then SUSPENDED | You, or your harness |
RevertActor | CRASHED, RUNNING, SUSPENDED with a snapshot on the node, and REVERTING (the call continues the revert) | REVERTING, then SUSPENDED | You, or your harness |
DeleteActor | every state | DELETING, then no actor | You, or your harness |
Substrate itself makes one move: it marks an actor ACTOR_STATE_CRASHED when the sandbox, the worker, or a restore is lost. Section 3.5 lists every way that we reached that state.
A call in the wrong state does not wait. ResumeActor on a crashed actor returns FailedPrecondition at once, and a request gets the same text as a 503. SuspendActor on a suspended actor returns the actor with no change.
The router calls ResumeActor for every request, also when the actor runs. On a running actor the call succeeds, changes nothing, and returns resumed=false. That is why a plain HTTP request is enough to start a suspended actor. Section 1.2 follows one such request.
Our position: read the state before you debug the network. kubectl ate get actors -a <atespace> costs one call. It separates a crashed actor from a full pool and from a dead process, and each of those needs a different fix.
SuspendActor and ResumeActor
Substrate suspends an actor only when a caller asks, and on our test server a resume cost about 2 ms for each MiB that holds data.
You created an actor in the afternoon, sent it five requests, and went home. The quickstart told you that Substrate checkpoints an idle actor. The next morning the actor is still ACTOR_STATE_RUNNING, and it still holds a full CPU of its worker. Nothing is broken. Substrate v1.0.0 has no idle timer.
When you finish this section, you can decide who calls SuspendActor in your system and choose between its two modes. You can also predict the resume time of an actor from the memory that it holds.
Substrate does not suspend an idle actor
We sent five requests to my-counter-1 and then sent nothing for 900 seconds. A script read the state every 15 seconds.
2026-11-12T10:14:22.822Z idle-watch-start no requests for 900 s
2026-11-12T10:14:37.832Z state ACTOR_STATE_RUNNING -
…
2026-11-12T10:29:35.957Z state ACTOR_STATE_RUNNING -
2026-11-12T10:29:36.168Z idle-watch-end
2026-11-12T10:29:36.265Z call-after-idle 200 0.080377 preserved memory count: 6 | preserved file counter: 6 | preserved rootfs counter: 6All 60 samples were ACTOR_STATE_RUNNING, and the sixth call continued the count in 0.08 seconds. The code agrees: no component of v1.0.0 suspends an actor because it is idle. The API reference says the same, and the quickstart page does not.
An idle running actor is not free. It keeps the CPU and the memory that its template booked on the worker. While my-counter-1 ran, its worker showed 1,000 of 1,000 millicores in use. Section 4.5 turns that into a pool size.
So the job belongs to the caller, and in practice to the agent harness. In our run, kagent suspended the actor after 98 of 98 turns. Section 7.1 shows that pattern and its cost.
SuspendActor: asynchronous or synchronous
SuspendActor: the call that checkpoints a running actor, stops its sandbox, and frees its place on the worker. It has two durability modes.
- The asynchronous mode is the default. The call returns when the snapshot is on the disk of the node. atelet uploads it to the object store later.
- The synchronous mode returns only when the snapshot is in the object store. Use
kubectl ate suspend actor --mode sync.
The actor object shows the difference.
2026-11-12T10:30:08.028Z state ACTOR_STATE_SUSPENDED kind-control-plane 5 SNAPSHOT_OWNER_TAG:SNAPSHOT_STORAGE_STATE_COMPLETED:gen1,SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_PENDING:gen2
…
2026-11-12T10:30:09.378Z state ACTOR_STATE_SUSPENDED kind-control-plane 9 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen3After the asynchronous suspend, generation 2 is SNAPSHOT_STORAGE_STATE_PENDING. The object store does not have it yet, so the older generation stays as the durable copy. After the synchronous suspend, generation 3 is SNAPSHOT_STORAGE_STATE_COMPLETED, and Substrate has released the older ones. Section 3.4 follows such a snapshot for 42 minutes.
| Memory held | Asynchronous suspend, median | Synchronous suspend, median |
|---|---|---|
| 0 MiB | 0.34 s | 0.40 s |
| 50 MiB | 0.50 s | 1.03 s |
| 200 MiB | 0.79 s | 2.02 s |
| 500 MiB | 1.17 s | 3.59 s |
| 1,000 MiB | 1.95 s | 6.41 s |
We measured the time until kubectl ate suspend actor returned, on the x86 server guest (24 vCPU, 47 GB, nested KVM) with gVisor. The asynchronous column has 10 suspends for each size, and the synchronous column has 5. The actor held random bytes, which do not compress. The extra time is close to the upload itself. In a separate run, atelet logged 2.0 to 2.1 seconds for each of four uploads of a 500 MiB snapshot. Raw runs
A suspend does not wait for open requests
A suspend stops the sandbox at once, also when the actor is in the middle of a request. We gave an actor a handler that sleeps 10 seconds, sent it one request, and suspended the actor 2 seconds later.
2026-11-12T12:52:10.848Z async slow-request-ended 503 2.218030 upstream connect error or disconnect/reset before headers. reset reason: connection termination
2026-11-12T12:52:10.992Z async suspend-2s-into-request 0.376s ATESPACE NAME TEMPLATE STATE WORKER POD WORKER IPS VERSION AGE lab-v sl-async lab-v/tiny-0 ACTOR_STATE_SUSPENDED…
…
2026-11-12T12:52:11.431Z async next-call 200 0.228121 tag=v3 count=2 mb=0 fill=zero sum=0 rootfs=-1 data=-1 pid=1 process_age_s=2.4 child[none]We measured this once in each mode on the x86 server guest (24 vCPU, 47 GB, nested KVM). The client of the open request got a 503 after 2.2 seconds in both modes. The suspend call returned success in 0.38 seconds in the asynchronous mode and 0.48 seconds in the synchronous mode, with no warning. The next request resumed the actor in 0.23 seconds, and the count continued at 2. Raw runs
Substrate does not replay the request that was cut. The snapshot holds the actor in the middle of its handler, and the client is gone when the handler ends. So the caller of SuspendActor must know that the actor is idle. A harness that suspends after each turn knows it. A timer that suspends by clock does not.
ResumeActor: who calls it and where it runs
ResumeActor: the call that places a suspended actor on a worker and restores its newest snapshot. The router makes this call for every request, so a plain request is enough to start an actor. kubectl ate resume actor makes the same call with no request.
A resume looks at the node first. After a suspend, the actor stays assigned to its node for as long as that node holds the snapshot. ResumeActor then tries the workers on that node. If the snapshot is only in the object store, atelet downloads it first, on the node of the worker that the scheduler chose.
Resume time against the memory held
We built an actor that holds a fixed amount of random memory and reads one byte in each MiB on every request. We suspended it, waited, and timed the next request.
| Memory held | From the node, median | Range of 10 | From the object store, median | Range of 5 |
|---|---|---|---|---|
| 0 MiB | 0.22 s | 0.22 to 0.23 s | 0.24 s | 0.24 to 0.25 s |
| 50 MiB | 0.24 s | 0.23 to 0.26 s | 0.41 s | 0.37 to 0.47 s |
| 200 MiB | 0.47 s | 0.35 to 0.54 s | 0.96 s | 0.90 to 1.01 s |
| 500 MiB | 1.07 s | 1.00 to 1.34 s | 2.26 s | 1.93 to 2.41 s |
| 1,000 MiB | 2.16 s | 1.94 to 5.09 s | 4.68 s | 4.23 to 4.97 s |
We measured one request to a suspended actor on the x86 server guest (24 vCPU, 47 GB, nested KVM), with gVisor and one kind node. The time is at the client, through kubectl ate proxy. A request to a running actor took 0.06 seconds on that path. For the object store column, atelet ran with an upload delay of 3 seconds and a detach delay of 10 seconds. All 55 requests of that run returned 200: 5 first calls, 25 resumes, and 25 calls to a running actor. Raw runs
The atelet log says where the second column loses its time.
{"time":"2026-11-12T11:37:01.905071369Z","level":"INFO","msg":"Restore timing breakdown",…"ate.actor.name":"o500",…"ate.snapshot.fidelity":"memory",…"ate.actor.restore.duration.download":1.171619862,…"ate.actor.restore.duration.worker_restore":0.326837467,"ate.actor.restore.duration.total":1.5049079490000001,…The download took 1.17 seconds of the 1.50 seconds that atelet counted. The worker needed 0.33 seconds for the restore itself.
Our rule of thumb for this machine has two numbers. A resume costs a fixed 0.2 seconds, plus about 2 ms for each MiB from the node. From the object store, it costs about twice as much for each MiB. The rule has four conditions, and you must check each one against your case.
- The memory was random, so every MiB held data. The rule counts the memory that holds data, as the next subsection shows.
- The object store ran in the same cluster and on the same disk. On another guest, 20 ms of added round trip to the bucket took a resume of 1,000 MiB from 4.8 s to 10.2 s. Section 8.1 has that run.
- The sandbox was gVisor. On the microVM class the same test cost a fixed 0.3 seconds plus about 1 ms for each MiB, up to 500 MiB. Section 4.2 has the comparison.
- The machine was a nested guest on a server CPU from 2019.
The project states that a resume takes less than 500 ms, and it gives no actor size and no machine. On our guest, that holds up to about 200 MiB from the node and up to about 50 MiB from the object store.
The rule counts memory that holds data
Random bytes are the worst case for a snapshot. We repeated the node path with three kinds of memory at 200 and 500 MiB.
| Memory held | All zero bytes | Half zero, half random | All random bytes |
|---|---|---|---|
| 200 MiB | 0.24 s | 0.28 s | 0.42 s |
| 500 MiB | 0.23 s | 0.57 s | 1.07 s |
We measured 10 resumes from the node for each cell, on the x86 server guest (24 vCPU, 47 GB, nested KVM). Each cell is the median, and the sandbox was gVisor. All 60 resumes returned 200, and every count continued. The random column repeats the earlier table within 12 percent. Raw runs
An actor with 500 MiB of zero pages resumed as fast as an empty actor. Half the data cost about half the extra time. So put the MiB that hold data into the rule, and not the limit of the template. Section 3.4 shows that the snapshot size follows the same bytes.
An ordinary runtime: Python with 245 MiB
Ballast is not an application. We checked the rule with a Python server that builds a dict of one million random 100-byte values at start. It keeps a counter in memory and a SQLite file on a durableDir volume.
import os, resource, sqlite3, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
started = time.time()
state = {"count": 0, "ready": False}
data = {i: os.urandom(100) for i in range(1_000_000)}
os.makedirs("/data", exist_ok=True)
db = sqlite3.connect("/data/app.db", check_same_thread=False)
db.execute("create table if not exists hits (n integer)")
db.commit()
state["ready"] = True
…| Step | Result |
|---|---|
| Resume from the node | median 0.41 s, 0.37 to 0.54 s, n=10 |
| First call of a new actor | median 0.63 s, 0.62 to 0.99 s, n=5 |
| Asynchronous suspend | median 0.81 s, n=10 |
| Synchronous suspend | median 1.93 s, n=3 |
| Snapshot on the node | 215.2 MiB |
We measured one actor of the image python:3.13-slim on the x86 server guest (24 vCPU, 47 GB, nested KVM). The sandbox was gVisor, the limit was 768 MiB, and the fidelity was memory. The process reported a peak resident size of 245 MiB. All 29 requests returned 200. The memory counter, the SQLite row count, and the dict continued across 13 suspends, and the process ID stayed 1. Raw runs
The rule holds. It gives 0.2 seconds plus 215 MiB at 2 ms, which is 0.63 seconds, and the measured median was 0.41 seconds. An interpreter heap is not all random data, so the rule is an upper estimate for it.
One detail matters for code that reads a clock. The process reported an age of 32.9 seconds at its first request and 65.1 seconds at its last, 13 suspends later. Under gVisor the clock of the actor does not count the time that it was suspended. Section 4.2 compares the two classes on this point.
The same test on Apple silicon
We measured the node path on an Apple silicon laptop under Docker Desktop, with 10 CPU and 8 GB for Docker. Each size had 10 resumes. The median resume was 0.09 seconds at 0 MiB, 0.09 seconds at 50 MiB, and 0.16 seconds at 200 MiB. The median asynchronous suspend was 0.10, 0.15, and 0.24 seconds. All 66 requests returned 200, and every count continued. We did not test more than 200 MiB or the object store path on the laptop. Raw runs
The laptop and the server guest are different machines. Read each table by itself, and do not take a ratio across them.
Our position
Call SuspendActor yourself, from the component that knows when a turn ends. Use the asynchronous mode for the normal case, because the caller waits a third as long at 1,000 MiB. Use the synchronous mode for any state that you cannot lose, because it makes the snapshot durable before the call returns.
A durable snapshot does not move the actor. In our two-node run a sync-suspended actor resumed on its own node while that node was cordoned. When the node stopped, the actor failed its requests and became ACTOR_STATE_CRASHED. A drain is the exception: three suspended actors resumed on the other node at their next request. Section 4.3 has the runs.
Suspend only an actor that has no open request. Budget the resume from the memory that holds data in your actor, and not from a headline number.
Snapshot fidelity, and where a snapshot lives
A snapshot keeps what its fidelity names, it is as large as the memory that holds data, and for five minutes it exists on one node only.
Your agent wrote a file to /tmp, started a background process, and ended its turn. On the next turn the file is gone and the process does not exist. Nothing failed. The template asked for a snapshot of files only, and /tmp is not one of those files. A second surprise waits on the node: the snapshot that you took a minute ago is not in the object store yet.
When you finish this section, you can choose a fidelity for a template and estimate the disk that suspended actors need. You can also say where a snapshot is at each minute after a suspend.
Three fidelities
Snapshot fidelity: the set of state layers that a snapshot holds. A template names the highest level that it wants in snapshotConfig.preferredFidelity, and each level includes the levels below it.
| Fidelity | The snapshot holds | A resume |
|---|---|---|
SNAPSHOT_FIDELITY_VOLUMES | The durableDir volumes | Boots the containers from the image, with the volumes back |
SNAPSHOT_FIDELITY_ROOTFS | The volumes, and the writes to the root filesystem since boot | Boots the containers from the image, with those writes back |
SNAPSHOT_FIDELITY_MEMORY | The volumes, the root filesystem writes, and the process memory | Continues the same processes |
kagent uses the first level. Its template mounts one durableDir volume at /data and sets SNAPSHOT_FIDELITY_VOLUMES. In the first turn of a session, we told the agent to write to three places and to start a process.
RUN: mkdir -p /data/workspace && echo hello-from-turn1 > /data/workspace/note.txt; echo scratch-turn1 > /tmp/scratch.txt; echo rootfs-turn1 > /root-marker.txt 2>/dev/null; (nohup sleep 100000 >/dev/null 2>&1 &) ;…kagent suspended the actor after that turn. The second turn read the same places.
note=hello-from-turn1
scratch=cat: can't open '/tmp/scratch.txt': No such file or directory
bg=
uptime=0.74The note under /data is there. The file in /tmp is gone, the sleep process is gone, and the sandbox is 0.74 seconds old. The write to / failed in turn 1, because the harness does not run as root.
The other end is SNAPSHOT_FIDELITY_MEMORY, which our counter and our memory test used. To compare all three levels, we ran one actor under each level. The actor holds 50 MiB of random bytes and starts a child process. It keeps one count in memory, one in a file on the root filesystem, and one in a file on a durableDir volume.
| Fidelity | Memory count | Root filesystem count | Volume count | Processes after a resume | Snapshot on the node | Resume, median of 10 |
|---|---|---|---|---|---|---|
SNAPSHOT_FIDELITY_VOLUMES | back to 1 | back to 1 | continued to 25 | new, 0.2 to 0.4 s old | 44 KiB | 0.52 s |
SNAPSHOT_FIDELITY_ROOTFS | back to 1 | continued to 25 | continued to 25 | new, 0.2 to 0.3 s old | 72 KiB | 0.50 s |
SNAPSHOT_FIDELITY_MEMORY | continued to 25 | continued to 25 | continued to 25 | the same, with the same child | 56.9 MiB | 0.25 s |
We measured these three actors on the x86 server guest (24 vCPU, 47 GB, nested KVM), with gVisor and one actor for each level. Each actor got 25 requests with 13 suspends between them, and all 75 requests returned 200. The resume column is one request to the suspended actor, timed at the client, with the snapshot on the node. An asynchronous suspend took a median of 0.32 seconds at the two lower levels and 0.54 seconds at the memory level, with 10 suspends each. Raw runs
A resume at the two lower levels is a cold start of the container. The snapshot is a few KiB, so the cost is the start time of your application. Our Go actor starts in 0.3 seconds, and its resume took 0.5 seconds, twice the resume at the memory level. The kagent run was on a second x86 server guest (16 vCPU, 31 GB). There the harness image resumed in a median of 0.38 seconds under SNAPSHOT_FIDELITY_VOLUMES.
A Python agent is slower to start. We ran one on the same guest, on the cluster of the Part 9 demos, with three turns at each level. A turn after a suspend took 4.5 to 5.1 seconds under SNAPSHOT_FIDELITY_VOLUMES. It took 0.30 to 0.32 seconds under SNAPSHOT_FIDELITY_MEMORY. A stateful agent actor shows that run.
preferredFidelity is an upper limit, and a memory snapshot can restore as less. A memory snapshot records the CPU architecture, the CPU features, and the sandbox build of the worker that took it. It restores only on a worker that matches. If no such worker has room, Substrate restores the files on another worker and boots the processes new. We did not see that downgrade in any run. Write your actor so that it survives a boot from its files, also at the memory level.
A snapshot is as large as the memory that holds data
Start with the worst case, an actor whose memory is random bytes.
| Memory the actor holds | Snapshot on the node |
|---|---|
| 0 MiB | 1.3 MiB |
| 50 MiB | 51.7 MiB |
| 200 MiB | 201.9 MiB |
| 500 MiB | 502.2 MiB |
| 1,000 MiB | 1,002.7 MiB |
One file holds almost all of it.
211574784 /var/lib/ate/snapshots/a77c2723-c524-46d0-9a7e-caa97f36ab9c/local-checkpoint/…/pages.imgWe measured these sizes with du on the kind node of the x86 server guest, one actor for each size. The memory was random bytes. The snapshot folder of an actor is named by the uid of the actor. Raw runs
Random bytes are the largest snapshot that an actor of that size can have. We ran the same actor again with zero bytes, and with half zero and half random bytes.
| Memory the actor holds | Snapshot on the node | Copy in the object store | Resume from the node, median | Asynchronous suspend, median |
|---|---|---|---|---|
| 200 MiB of zero bytes | 4.0 MiB | 1.1 MiB | 0.24 s | 0.35 s |
| 500 MiB of zero bytes | 4.6 MiB | 1.1 MiB | 0.23 s | 0.35 s |
| 200 MiB, half zero and half random | 103.8 MiB | 101.1 MiB | 0.28 s | 0.66 s |
| 500 MiB, half zero and half random | 254.4 MiB | 251.2 MiB | 0.57 s | 0.79 s |
| 200 MiB of random bytes | 203.8 MiB | 201.2 MiB | 0.42 s | 0.80 s |
| 500 MiB of random bytes | 504.1 MiB | 501.3 MiB | 1.07 s | 1.07 s |
We measured six actors on the x86 server guest (24 vCPU, 47 GB, nested KVM), one for each row. Each actor had 10 resumes and 10 asynchronous suspends. All 60 resumes returned 200, and every count continued. Each size is one du reading, on the node and in the data folder of the object store. Raw runs
So the size follows the memory that holds data, and the resume time follows the same bytes. An actor with 500 MiB of zero pages resumed in 0.23 seconds, as fast as an actor that holds nothing. Do not count on that for a real heap. A Python process with a dict of one million random values reported a peak of 245 MiB. Its snapshot on the node was 215.2 MiB. Section 3.3 has its resume time.
The object store did not make the random snapshots smaller. Substrate compresses each object before the upload, and random bytes do not compress. The copy of the Python actor was 110.1 MiB in the object store, about half of its node copy. We did not find out which step made it smaller.
| Where | With 25 suspended actors | After we deleted them |
|---|---|---|
| Snapshots on the node | 8,801 MiB | 1 MiB |
| Snapshot cache on the node | 1,765 MiB | 1,765 MiB |
| Object store | 10,824 MiB | 2,064 MiB |
We measured this footprint on the x86 server guest (24 vCPU, 47 GB, nested KVM). The node had five actors for each of the five sizes, which hold 8,750 MiB together. Each actor was 3 to 8 minutes old. The 2,064 MiB that stayed in the object store belong to golden snapshots. A template keeps its golden snapshot until you delete the template. Raw runs
An actor owns one snapshot in the object store, or two after an asynchronous suspend. A synchronous suspend deletes the earlier snapshot. An asynchronous suspend keeps it, because the new snapshot is not yet durable. The first suspend after the new snapshot is durable deletes the old one, and a delete of the actor removes both.
So plan the object store for up to two times the memory that holds data in all suspended actors. Add one golden snapshot for each template. Plan the node disk for the actors that were suspended in the last 30 minutes. Add the snapshot cache, which still held 1,765 MiB here after we deleted all 25 actors.
Two timers decide where the snapshot is
Node-local snapshot: the copy that the worker writes to the disk of its node at a suspend. atelet owns it.
Durable snapshot: the copy in the object store. Its state is SNAPSHOT_STORAGE_STATE_PENDING until the upload is complete, and then SNAPSHOT_STORAGE_STATE_COMPLETED.
After an asynchronous suspend, atelet runs two timers for each snapshot.
--snapshot-upload-delay, 5 minutes by default: the wait before the upload to the object store.--snapshot-detach-delay, 30 minutes by default: the time until atelet removes the copy on the node and clears the node assignment of the actor.
We suspended five actors and read their state every 10 seconds for 42 minutes. The fifth actor, at 1,000 MiB, never got a worker, because the pool had no room. It made no snapshot of its own, so the numbers below are for four actors.
2026-11-12T10:31:10.270Z d0 suspended-async ACTOR_STATE_SUSPENDED kind-control-plane …SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_PENDING:gen2:kind-control-plane
…
2026-11-12T10:36:01.379Z d0 state ACTOR_STATE_SUSPENDED kind-control-plane …SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_PENDING:gen2:kind-control-plane
2026-11-12T10:36:12.367Z d0 state ACTOR_STATE_SUSPENDED kind-control-plane …SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:kind-control-plane2026-11-12T11:01:02.649Z d0 state ACTOR_STATE_SUSPENDED kind-control-plane …SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:kind-control-plane
2026-11-12T11:01:13.670Z d0 state ACTOR_STATE_SUSPENDED - …SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:-We measured four actors on the x86 server guest (24 vCPU, 47 GB, nested KVM), with the default timers. They held 0, 50, 200, and 500 MiB. The upload was complete 301 to 311 seconds after the suspend. The node copy was gone 1,801 to 1,803 seconds after the suspend. At that moment the snapshots on the node shrank from 758 MiB to 1 MiB. Each time can be up to 10 seconds late, because that was our sample interval. Raw runs
The three periods mean three different things for you.
- In the first 5 minutes, the node holds the only copy. If that copy is lost, the actor is
ACTOR_STATE_CRASHED, and the work since the last durable snapshot is gone. Section 3.5 shows that loss. - From 5 to 30 minutes, both copies exist. A resume uses the node and takes the lower line of section 3.3.
- After 30 minutes, only the object store has the snapshot. A resume downloads it first and takes about twice as long for each MiB. That number is for a bucket in the cluster, and section 8.1 shows a bucket that is 20 ms away.
The documentation promises neither the start nor the end of the upload. Treat 5 minutes as the shortest exposure, not as a limit.
Our position: do not accept the first period for state that matters. A synchronous suspend closes it for one actor. A shorter --snapshot-upload-delay on atelet makes it shorter for the cluster, and does not close it. We ran atelet with 3 seconds and 10 seconds for one test, and all 55 requests of that test succeeded. A short detach delay saves node disk and makes every resume a download.
A tag is a copy that does not depend on the node
Tag: a named copy of the durable snapshot of a suspended actor. kubectl ate create tag <name> --actor <actor> makes one. If the snapshot is not uploaded yet, the call uploads it first. kubectl ate create actor <new> --tag <name> creates a new actor that starts from the copy.
A tag has its own object in the object store, under the atespace and outside the folder of the actor. So it does not need the node of the actor, and it does not change when the actor runs on. That makes a tag the one way to move the state of an actor off a node that has stopped.
ATESPACE NAME SCOPE STATE SNAPSHOT FIDELITY AGE
lab-n 09fee120-cd19-4121-8bae-5623fcbe70eb TAG_SCOPE_PUBLISHED Ready gs://ate-snapshots/atespaces/lab-n/tags/1010ca2e-25b0-44d6-864f-a31a80da48c2 SNAPSHOT_FIDELITY_MEMORY 147m
lab-n d8328610-2fdb-4974-a6ab-7c8671586d37 TAG_SCOPE_PUBLISHED Ready gs://ate-snapshots/atespaces/lab-n/tags/8d60620d-653e-469f-9dd1-9f11a89ce7e0 SNAPSHOT_FIDELITY_MEMORY 147m
lab-n keep-t1 TAG_SCOPE_ATESPACE Ready gs://ate-snapshots/atespaces/lab-n/tags/aca8c8e0-6dfd-4ee5-9e55-ced17f7c346e SNAPSHOT_FIDELITY_MEMORY 12m
lab-n keep-u1 TAG_SCOPE_ATESPACE Ready gs://ate-snapshots/atespaces/lab-n/tags/13e4dabd-ad64-417f-95a5-ec1d5909c786 SNAPSHOT_FIDELITY_MEMORY 72s
lab-n pend-u1 TAG_SCOPE_ATESPACE Ready gs://ate-snapshots/atespaces/lab-n/tags/cfefcc57-5a75-418c-ae29-424958706266 SNAPSHOT_FIDELITY_MEMORY 69sWe measured three tags in the node-stop cases, on a second x86 server guest (16 vCPU, 23 GB), with one run for each case. A tag of an actor that was suspended with --mode sync was Ready in less than 1 s. So was a tag of an actor whose newest snapshot was still PENDING on the node, and it held that newest state. Then we stopped the node. A new actor from each tag served its first request 1.2 s after the create command, on the other node. That first request took 0.48 to 0.58 s. Raw runs
A tag has three limits. It is as old as the suspend that it copied, so later work is not in it. The actor that you create from it has a new name. And you cannot make it after the fact: create tag on an actor that was held on the stopped node failed with must be ACTOR_STATE_SUSPENDED to be tagged. Section 8.2 uses tags in its runbook for a stopped node.
Do not delete a golden tag
The golden tag of a template is in the same list. Its scope is TAG_SCOPE_PUBLISHED, and its name is a uid. kubectl ate delete tag accepts it, and our own clean-up made that mistake. It deleted every tag of the atespace, and the golden tags of two templates went with them.
## get actor-templates
ATESPACE NAME GOLDEN TAG ERROR AGE
lab-n ballast-0 d8328610-2fdb-4974-a6ab-7c8671586d37 170m
lab-n ballast-50 09fee120-cd19-4121-8bae-5623fcbe70eb 170m
## get tags
ATESPACE NAME SCOPE STATE SNAPSHOT FIDELITY AGE
## create actor from template ballast-50
Error: failed to create actor: rpc error: code = FailedPrecondition desc = Tag not found
…The template looks healthy and is broken. get actor-templates still lists the old golden tag id, and the ERROR column is empty. Each create actor --template fails with Tag not found. We saw this once, on the same guest. We made new templates to continue, and we did not test whether a golden tag can be restored.
CRASHED and RevertActor
A crashed actor stays crashed until you call RevertActor, which returns it to its last completed snapshot and drops every call after it.
A node of your cluster restarts in the night. In the morning, some sessions answer 503 with a text about AssignWorker. One answers 503 with a text about an upstream connection. kubectl get pods shows every pod as Running. Substrate will not repair any of these sessions by itself, and a new request will not either.
When you finish this section, you can tell from an error text which state an actor is in. You can also bring the actor back with RevertActor and say which calls it lost.
Every way we reached CRASHED
ACTOR_STATE_CRASHED: the state of an actor whose sandbox, worker, or restore was lost. The actor has no worker. Its status.crash field holds a time and a message.
| What we did | First response to the caller | status.crash.message |
|---|---|---|
| Deleted the worker pod of a running actor | 503, AssignWorker prerequisite not met | worker pod went away while hosting the actor |
| Removed the snapshot on the node before the upload, then sent a request | 500, error resuming actor lab-c/x1 | resume failed: atelet Restore: snapshot … has no usable local copy and no uploaded copy |
| Restarted the node before the upload, then sent a request | 500, error resuming actor lab-c/r1 | resume failed: atelet Restore: worker restore failed with a retryable error 3 times in a row, giving up |
| Let the actor allocate memory past its limit | 503, upstream connect error, then 503, AssignWorker prerequisite not met | killed for exceeding its memory limit |
| Restarted the node under a running actor | 503, AssignWorker prerequisite not met | not recorded by us |
The first row is the plain case. The actor object says what happened.
status:
assignedNode: kind-control-plane
crash:
crashTime: "2026-11-12T10:30:11.170436971Z"
message: worker pod went away while hosting the actor
lastAssignedGeneration: 3
…
state: ACTOR_STATE_CRASHEDThe second and third rows are the cost of an asynchronous suspend. In both, the newest snapshot was SNAPSHOT_STORAGE_STATE_PENDING, so one node held the only copy. In both, the actor still looked healthy: its state was ACTOR_STATE_SUSPENDED. The request that tried to resume it found the loss and crashed it.
2026-11-12T11:30:18.606Z x1 removed-node-local-snapshot uid 085b45fb-edb9-4523-ad30-3da55b3f34f0
2026-11-12T11:30:18.708Z x1 call-after-loss 500 0.082402 error resuming actor lab-c/x1
2026-11-12T11:30:18.911Z x1 state ACTOR_STATE_CRASHED kind-control-plane …
2026-11-12T11:30:23.997Z x1 call-again 503 0.063957 actor lab-c/x1 unavailable: AssignWorker prerequisite not met for Actor: lab-c/x1 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED)
…
2026-11-12T11:30:25.241Z x1 call-after-revert 200 0.416152 count=4 mb=50 fill=random sum=6296 process_age_s=0.8
2026-11-12T11:30:25.434Z x1 state ACTOR_STATE_RUNNING kind-control-plane …Substrate did not use the older, completed snapshot by itself. The actor had one in the object store, and the request still failed.
The node restart has a detail that the table hides.
We measured one node restart on the x86 server guest. The Kubernetes API answered after 8 seconds, and all pods ran after 147 seconds. We sent the request less than two seconds later. The crash message does not name a lost snapshot. It says that the restore failed three times because the worker had not registered yet. So a request that arrives too early after a node restart can crash a suspended actor, and we did not test a later request. Raw runs
One event did not crash anything. We deleted the atelet pod while a snapshot was pending, and the next request returned 200 with the count intact. The snapshot is on the disk of the node, not in atelet.
What the caller sees
Two texts tell you that an actor is crashed.
error resuming actor <atespace>/<name>, with status 500. Only the request that finds the loss gets this text.actor <atespace>/<name> unavailable: AssignWorker prerequisite not met for Actor: <atespace>/<name> (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED), with status 503. Every later request gets this text, and it arrives at once.
kubectl ate resume actor is refused with the same text and the gRPC code FailedPrecondition.
2026-11-12T10:30:17.301Z resume-while-crashed 0.207s Error: failed to resume actor: rpc error: code = FailedPrecondition desc = AssignWorker prerequisite not met for Actor: ate-demo-counter/my-counter-1 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENNo retry helps. Do not put this 503 in the same retry loop as a 503 from a full pool.
Running in the state, dead in the sandbox
One failure does not reach ACTOR_STATE_CRASHED at all. We told an actor to call os.Exit(3).
2026-11-12T11:30:45.229Z j1 exit-request 200 0.065314 exiting with code 3
2026-11-12T11:30:47.441Z j1 state ACTOR_STATE_RUNNING kind-control-plane …
2026-11-12T11:30:47.518Z j1 call 503 0.058459 upstream connect error or disconnect/reset before headers. reset reason: connection termination
…
2026-11-12T11:31:00.122Z j1 suspend-attempt Error: failed to suspend actor: rpc error: code = Internal desc = internal server error: workflow failed at step CallAteletSuspend: actor lab-c/j1 crashed…
2026-11-12T11:31:00.333Z j1 state ACTOR_STATE_CRASHED kind-control-plane …We measured one actor for each kind of death on the x86 server guest. After the exit, the state was ACTOR_STATE_RUNNING in 6 of 6 samples over 14 seconds, and all 6 requests got the 503 from Envoy. A memory kill is different: Substrate recorded the crash 2.3 seconds after the request that started the allocation. One request in that gap got the Envoy text. Raw runs
Fourteen seconds do not prove much, so we repeated the exit and watched for 20 minutes. One actor got no request. A second actor got one request each 60 seconds.
2026-11-12T12:45:20.264Z e1 exit-request 200 0.068987 exiting with code 3
2026-11-12T12:45:36.467Z e1 state ACTOR_STATE_RUNNING kind-control-plane …
…
2026-11-12T13:05:57.588Z e1 call-after-watch 503 0.070948 upstream connect error or disconnect/reset before headers. reset reason: connection termination
2026-11-12T13:05:57.886Z e1 suspend-attempt Error: failed to suspend actor: rpc error: code = Internal desc = internal server error: workflow failed at step CallAteletSuspend: actor lab-v/e1 crashed…
2026-11-12T13:05:58.168Z e1 state ACTOR_STATE_CRASHED kind-control-plane …
2026-11-12T13:05:58.604Z e1 after-revert ACTOR_STATE_SUSPENDED - …We measured two actors on the x86 server guest (24 vCPU, 47 GB, nested KVM), for 20 minutes 36 seconds after the exit. Both were ACTOR_STATE_RUNNING in every sample, 81 and 80 samples at 15 second intervals. All 20 requests to the second actor returned the 503 from Envoy. The first suspend after the watch failed and marked the actor crashed. A revert returned it to ACTOR_STATE_SUSPENDED, and the next call returned 200 with the count of the last durable snapshot. Raw runs
So Substrate did not find a process exit by itself in 20 minutes, with requests or without. Each dead actor also kept one CPU of its worker for all that time, which section 4.5 counts. We did not watch longer, and we did not restart the worker.
In both runs a suspend found the exit. The checkpoint failed, and the failed suspend marked the actor crashed.
Your harness must cover the exit case. If requests return upstream connect error and the state is ACTOR_STATE_RUNNING, call SuspendActor and expect it to fail. A harness that suspends after every turn does this without extra code.
An actor on a node that stops
A node that stops and stays down gives three more cases, and none of them starts as ACTOR_STATE_CRASHED. We ran them on a cluster with two worker nodes and stopped one of the two. Placement has the timeline and the scheduler side.
| The actor when the node stopped | State that you see | Text of the request or the call |
|---|---|---|
| Suspended, and then a request arrives | ACTOR_STATE_RESUMING for minutes, then ACTOR_STATE_CRASHED | 504 at the client timeout, then 503 actor lab-n/u1 unavailable after 5 to 8 s |
| Running | ACTOR_STATE_RUNNING | 503 upstream connect error or disconnect/reset before headers. reset reason: connection termination |
Either one, when you call revert | ACTOR_STATE_RESUMING or ACTOR_STATE_REVERTING | another operation is in progress for this actor, or is not in a revertable state (got: ACTOR_STATE_RESUMING, or while calling atelet Terminate |
We measured these cases on the second x86 server guest (16 vCPU, 23 GB), with gVisor and one actor for each case. A suspended actor with a complete snapshot stayed in ACTOR_STATE_RESUMING for 5 min 33 s from its first request. It was crashed 5 min 44 s after the stop command. A suspended actor with a pending snapshot was still in ACTOR_STATE_RESUMING 10 min 42 s after the stop command. It had received no request in the last 8 min 43 s. A running actor stayed ACTOR_STATE_RUNNING for the 10 minutes and the 150 requests of our probe. Raw runs
2026-11-12T12:22:55.258Z node-stop docker stop multi-worker2
2026-11-12T12:23:15.930Z first-request 504 10.054853 | ACTOR_STATE_RESUMING multi-worker2 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:-,SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_PENDING:gen3:multi-worker2
2026-11-12T12:23:16.115Z revert-attempt-1 Error: failed to revert actor: rpc error: code = Aborted desc = another operation is in progress for this actor
…
2026-11-12T12:33:37.802Z revert-attempt-2 Error: failed to revert actor: rpc error: code = FailedPrecondition desc = Actor lab-n/p3 is not in a revertable state (got: ACTOR_STATE_RESUMING, want one of [ACTOR_STATE_RUNNING ACTOR_STATE_CRASHED], or ACTOR_STATE_SUS
2026-11-12T12:33:38.407Z probe-after-revert 503 0.193436 actor lab-n/p3 unavailable: AssignWorker prerequisite not met for Actor: lab-n/p3 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED) | ACTOR_STATE_CRASHED multi-worker2 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:-,SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_PENDING:gen3:multi-worker2Three rules follow from these runs.
ACTOR_STATE_RESUMINGthat lasts is a symptom. A resume of a small actor takes less than a second, and this state lasted for minutes. In the first run the crash came one second after Kubernetes evicted the worker pods, 5 minutes after the node wasNotReady. Without a request, nothing moved the actor out of the state.- A running actor on a stopped node is not marked crashed. The caller gets the text of a process that exited. To tell the two cases apart, run
kubectl ate get workers. On a stopped node the worker isWORKER_STATE_UNAVAILABLE, or it is not in the list. - A revert did not help while the node was down. It failed or had no lasting effect in 4 of 4 attempts on three actors. The revert of the running actor left it in
ACTOR_STATE_REVERTING. Each request then got 503 with(got: ACTOR_STATE_REVERTING, want ACTOR_STATE_SUSPENDED).
A second revert is the way out of ACTOR_STATE_REVERTING, and only after the node is back. RevertActor accepts an actor that is already in that state, and it continues from the step where the earlier attempt stopped. A later set of node-stop cases tested it.
We measured one actor that was suspended with --mode sync, on a second x86 server guest (16 vCPU, 23 GB), in one run with five reverts. The first revert started 10 s after the stop command, failed after 20.2 s, and left the actor in ACTOR_STATE_REVERTING. Three more failed in 0.2 s each, at 50 s, at 71 s, and at 371 s. Then we started the node. Its atelet ran 15 s later, and the actor was ACTOR_STATE_CRASHED by then. The fifth revert returned in 0.2 s, and the next request returned 200 in 0.47 s on the other node. Raw runs
2026-11-12T14:51:05.501Z t=30.6 revert-1-right-after-stop 20.2 s | Error: failed to revert actor: rpc error: code = Unavailable desc = while calling atelet Terminate: …
2026-11-12T14:51:24.853Z t=50.0 revert-2-after-workers-unavailable 0.2 s | Error: failed to revert actor: rpc error: code = Unavailable desc = while calling atelet Terminate: …
…
2026-11-12T14:56:45.527Z t=370.7 revert-4-after-eviction 0.2 s | Error: failed to revert actor: rpc error: code = Unavailable desc = while calling atelet Terminate: …
…
2026-11-12T14:57:00.955Z t=386.1 atelet-on-node-running atelet-gsmxg:Running atelet-tfq5g:Running
2026-11-12T14:57:01.180Z t=386.3 state-before-revert-5 ACTOR_STATE_CRASHED multi-worker2 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:multi-worker2
2026-11-12T14:57:01.440Z t=386.6 revert-5-after-node-return 0.2 s | ATESPACE NAME TEMPLATE STATE WORKER POD WORKER IPS VERSION AGE lab-n i9 lab-n/nb-50 ACTOR_STATE_SUSPENDED <none> …So a repeated revert does not finish the job while the node is down. In the other cases, 3 to 28 more reverts of an actor in ACTOR_STATE_REVERTING all failed. Each failed revert is one more call to an atelet that is not there.
The result after the return has a caveat. In four earlier cases the actor still showed ACTOR_STATE_REVERTING 216 to 223 s after the node start, and we sent no command in that time. In those cases the pool pods had been evicted, and the node came back with no pool pod. Our clean-up then removed those actors, and we did not record each result. So the state cleared by itself after the return in one run, and it did not in four. After a node returns, look at the state, and send the revert again if it is still ACTOR_STATE_REVERTING.
The path that the first runs left open is also measured. A revert that waits until the workers show WORKER_STATE_UNAVAILABLE frees one kind of actor: suspended with --mode sync, and with no request since the stop. It took 7.1 s, and the next request ran on the other node. Section 8.2 has the runbook for every case.
Do not revert an actor that you suspended in the default mode. Its newest snapshot is still PENDING on the stopped node, and it comes back with the node. In one run the node returned after 81 s. A request with no revert then returned 200 in 0.36 s, with count 6 after 5. A second actor in the same state got a revert first. Its next reply had count 4, so the two calls after its last durable snapshot were lost.
RevertActor
RevertActor: the call that discards the current execution of an actor and returns it to ACTOR_STATE_SUSPENDED at its last completed durable snapshot. It accepts a crashed actor, a running actor, and a suspended actor whose newest snapshot is on a node. It also accepts an actor in ACTOR_STATE_REVERTING, so that you can send a revert again. In our run that second revert worked only after the node of the actor was back.
A revert stops any sandbox, removes the snapshot files on the node, and deletes an upload that was not complete. The actor comes back with no assigned node, and the next request resumes it from the object store.
| Run | Last completed snapshot | Calls made after it | First reply after the revert |
|---|---|---|---|
| Worker pod deleted | count 8 | 9, 10, 11 | count 9 |
| Snapshot on the node removed | count 3 | 4, 5 | count 4 |
| Node restarted | count 3 | 4, 5 | count 4 |
The calls in the third column are gone. The actor answers as if they never happened.
We measured one revert of the counter actor on the x86 server guest: kubectl ate revert actor returned in 0.22 seconds. The first request after a revert took 0.38 to 0.42 seconds in three runs. After the node restart, that first request waited 5 seconds and got no worker has room for the actor, although all pods ran. A request 78 seconds later returned 200. Placement explains that 503: for some seconds after the control plane returns, no worker of the pool is active. Raw runs
A revert rewinds the actor only. A tool call that the actor made after the snapshot stays made, and an external volume keeps its contents. We did not test external volumes.
Our position: decide in your harness what a crash means, because Substrate will not. After our node restart, my-counter-1 stayed crashed until we reverted it by hand. A revert is safe to automate for an actor whose last calls can be sent again without a second effect. A tool call that already ran stays done, so the tool must ignore a repeat. For any other actor, suspend with --mode sync at each point that you cannot lose.
Atespaces and names
An atespace groups actors, templates, and tags in the Substrate API, and it is not a Kubernetes namespace, although the two often share a name.
You ran kubectl get ns to find where your actors live. You found a namespace with the name of your demo, with worker pods in it and no actor. Then you created a second atespace for a new team, and its template still ran on the pool of the first team. Both results follow from one fact: Substrate keeps two lists of names, and it connects them with labels only.
When you finish this section, you can create an atespace and name an actor and a template in it. You can also select a worker pool from a template, and find a snapshot in the object store from its URI.
An atespace is not a namespace
Atespace: the group that an actor, an ActorTemplate, and a tag belong to in the Substrate API. An atespace is a row in Postgres. It must exist before you create anything in it, and you can delete it only when it is empty.
| Object | Where it lives | How you list it |
|---|---|---|
| Atespace | Substrate API, global | kubectl ate get atespaces |
| Actor | Substrate API, in one atespace | kubectl ate get actors -a <atespace> |
| ActorTemplate | Substrate API, in one atespace | kubectl ate get actor-templates -a <atespace> |
| Tag | Substrate API, in one atespace | kubectl ate get tags -a <atespace> |
| Access policy | Substrate API, one for each atespace | section 5.2 |
| WorkerPool, worker pods | Kubernetes, in one namespace | kubectl get workerpools -n <namespace> |
| Worker record | Substrate API, global | kubectl ate get workers |
Our test setup made both kinds of object with one name. That is our choice, and Substrate did not ask for it.
kind: Namespace
metadata:
name: lab-c
…
kind: WorkerPool
metadata:
name: ballast
namespace: lab-c
labels:
workload: ballast
…
kubectl apply -f pool.yaml
kubectl ate create atespace lab-c 2>&1 | tail -1kubectl apply wrote the namespace and the pool to Kubernetes. kubectl ate create atespace wrote one row through ate-api-server. The quickstart demo does the same with the name ate-demo-counter, so a new user sees one name and assumes one object.
The atespace is the boundary for access. A role in an atespace covers every actor and every template in it, and no grant exists for one actor. Do not use the name of a deleted atespace again. The documentation warns that a request authorized for the old atespace can reach the new one.
Pool selection crosses namespaces
A template selects its workers with workerSelector.matchLabels. The scheduler compares those labels with the labels on the WorkerPool object. It does not read the labels of the pods, and it does not read the namespace of the pool. Each worker carries a copy of the labels of its WorkerPool object, and the scheduler matches against that copy.
Three rules follow from the code and the documentation.
- Only equal labels match. No set expressions exist.
- An actor can add its own selector. The scheduler applies both selectors together, so the selector of the actor only narrows the choice. A change takes effect at the next resume.
- A template with no selector can run on every pool.
So the namespace of a pool is not a fence. A template in any atespace can name the labels of any pool, and Kubernetes RBAC on that namespace does not stop it. No document at v1.0.0 states this rule. It follows from the code, and our run below confirms it.
We ran it. We created an atespace xa and no Kubernetes namespace of that name. A template in xa selected workload: vpool, the label of a pool in the namespace lab-v.
ATESPACE NAME TEMPLATE STATE WORKER POD …
xa x1 xa/cross-0 ACTOR_STATE_RUNNING lab-v/vpool-69595c64bf-p4slt …The template built its golden snapshot on that pool, and the actor ran on a worker pod of lab-v.
A selector that matches no pool is the opposite mistake, and Substrate does not refuse it. We created a second template in xa with the label workload: no-such-pool. The create call succeeded. The template never got a golden tag, and its ERROR column stayed empty. A request to an actor of that template failed.
2026-11-12T12:51:40.304Z call-unmatched-selector 503 5.060180 actor xa/x2 unavailable: no worker has room for the actorWe measured both templates once on the x86 server guest (24 vCPU, 47 GB, nested KVM). The first request to the actor on the pool of lab-v returned 200 in 0.26 seconds. The request with the wrong selector waited 5.06 seconds for its 503. Raw runs
That is the text of a full pool, word for word. ate-api-server logged the same reason for the template 8 times in 40 seconds of its log, and the template showed no error. When you see no worker has room for the actor, compare the selector with the pool labels before you add workers. Section 8.3 has the row.
kagent shows the convention that fills this gap. It writes the name and the namespace of the pool into two labels, and its templates select both.
workerSelector:
matchLabels:
kagent.dev/worker-pool-name: kagent-default
kagent.dev/worker-pool-namespace: kagentThe second label looks like a namespace check, and it is a label like every other. A pool in another namespace with the same two labels would match.
The worker list has one filter for each list of names.
-a, --atespace string filter worker pods hosting actors in a specific atespace
…
-n, --namespace string scope output to a specific Kubernetes namespace
--sandbox-class string filter by sandbox class (e.g. gvisor, microvm)
-l, --selector string filter by worker pool labelsNames
Every name in the Substrate API is a DNS label: lowercase letters, digits, and hyphens, at most 63 characters. A name is fixed at create time.
| What | Form | Example from our runs |
|---|---|---|
| Actor address | <atespace>/<name> | ate-demo-counter/my-counter-1 |
| Template reference | <atespace>/<name> | lab-c/ballast-200 |
| Actor uid | assigned by the server | 6f80f612-7547-4f5b-b3ab-831595621806 |
| Worker name | the uid of the worker pod | c572e96d-dd9b-4500-b0e3-880a98d9f61d |
| Golden tag name | the uid of the template | b9f97b82-b187-419d-8789-6c8c7b8a6c8c |
| kagent actor | session-<session id> | session-01a1cf43-d760-747a-b907-5e0640126e4e |
The actor address is the form that you will read most. Error texts use it, kubectl ate takes its two halves as -a and the name, and the router reads it from a request header. Section 6.1 covers that header.
The uid matters when you delete an actor and create it again with the same name. The new actor gets a new uid. Snapshot paths use the uid, so the new actor cannot resume from the state of the old one.
The snapshot URI layout we saw
Each snapshot in an actor object has a snapshotUri. The atespace and the owner are part of the path.
snapshotUri: gs://ate-snapshots/atespaces/ate-demo-counter/tags/ad19ee28-1519-432a-bcc5-40cf99cce3ca
…
snapshotUri: gs://ate-snapshots/atespaces/ate-demo-counter/actors/6f80f612-7547-4f5b-b3ab-831595621806/snapshots/8faccdff-15cc-493d-8474-5ebcb1c18d56| Owner | Layout |
|---|---|
| A tag | <location>/atespaces/<atespace>/tags/<tag uid> |
| An actor | <location>/atespaces/<atespace>/actors/<actor uid>/snapshots/<snapshot uuid> |
| An actor, on the node | /var/lib/ate/snapshots/<actor uid>/local-checkpoint/<snapshot uuid> |
We saw three details that the layout does not tell you.
- On a kind install the location is
gs://ate-snapshots/, although the store is the rustfs pod in the cluster. - kagent sets a location for each template, and its URIs start with
s3://ate-snapshots/kagent/. That field is a preview feature in the upstream project. - The last part of a golden URI is the uid of the tag. It is not the name of the tag, which is the uid of the template.
The atespace is in the path for a reason. An object store can grant access by path prefix, so you can give one team read access to the snapshots of one atespace. We did not test such a grant.
Our position: use one name for the atespace and the namespace when one team owns both, as the demos do. Then treat the labels of your pools as placement, and not as a fence. Any atespace can name them, and we found no setting at v1.0.0 that limits a pool to one atespace. Choose the labels on purpose, and check which templates name them.
Workers and Capacity
A WorkerPool decides how many actors run at one time, and a full pool answers with a 503 after five seconds.
WorkerPool
A WorkerPool is a set of worker pods that you size, and each pod reports a capacity taken from its own CPU and memory limits.
You create an actor, send it a request, and look for its pod. There is no pod with the name of the actor. The actor runs inside a worker pod that existed before the actor did, and that hosts other actors too. The WorkerPool is the object that made that pod.
When you finish this section, you can write a WorkerPool, read the output of kubectl ate get workers, and say where each capacity number comes from.
Three CRDs, and no Actor among them
WorkerPool: a namespaced Kubernetes object that asks for a number of worker pods of one sandbox class.
Substrate adds three CRDs to your cluster, all in the group ate.dev at version v1. A WorkerPool is the only one you write often. A SandboxConfig names the sandbox binaries for a class, and the install creates a default for each class. A CSIDriverConfig belongs to a preview feature for external volumes.
csidriverconfigs.ate.dev Cluster v1(storage) 2026-11-12T10:05:33Z
sandboxconfigs.ate.dev Cluster v1(storage) 2026-11-12T10:05:33Z
workerpools.ate.dev Namespaced v1(storage) 2026-11-12T10:05:33ZActors, ActorTemplates and atespaces are absent from that list. They are rows in Postgres that ate-api-server serves, so kubectl get cannot show them. Use kubectl for the pool and kubectl ate for all that runs on the pool.
The pool we used
This is the pool behind most measurements in this manual. It asks for two gVisor workers, each with 2 CPUs and 3 GiB.
apiVersion: ate.dev/v1
kind: WorkerPool
metadata:
name: ballast
namespace: lab-c
labels:
workload: ballast
spec:
replicas: 2
workerImage: localhost:5001/ateworker-gvisor@sha256:ad6957f41bccaa8bb5f3eb5b34b7812babb862f1a3187a11578c21a7d799491c
sandboxClasses:
- name: gvisor
template:
resources:
limits:
cpu: "2"
memory: 3Gi
requests:
cpu: 500m
memory: 3GiFour fields carry the meaning:
spec.replicasis the number of worker pods. The pool has ascalesubresource, sokubectl scaleand an autoscaler can change it.spec.workerImageis the worker binary for the class. Useateworker-gvisorforgvisorandateworker-microvmformicrovm.spec.sandboxClassestakes exactly one entry at v1.0.0, and you cannot change itsnamelater. Sandbox classes compares the two.spec.template.resources.limitsis the capacity of each worker. It is the most important field in the object.
The label workload: ballast sits on the pool object, under metadata.labels. An ActorTemplate selects pools with workerSelector.matchLabels, and the match reads the labels of the pool object. Labels under spec.template.labels go to the Deployment and its pods, and they do not affect which actors the pool accepts.
A selector is the only link between a template and a pool. The namespace of the pool and Kubernetes RBAC do not limit which templates can select it. An actor can add its own workerSelector, which narrows the selector of the template and takes effect at the next resume. Put a label on each pool that only the intended templates use.
From the pool to a registered worker
Four steps separate your YAML from a worker that can take an actor:
ate-controllerreads the WorkerPool and writes a Deployment with the same replica count. Each pod has the labelate.dev/worker-pool.ate-controllerwatches those pods and creates one Worker record for each inate-api-server. The name of the record is the UID of the pod.ateleton the node registers the worker and reports which sandbox runtimes it can run.- When the pod is Ready, the Worker moves to
WORKER_STATE_ACTIVE. Only an active worker receives actors.
Worker: the record in ate-api-server for one worker pod, with its state, its capacity, and the resources that actors have booked on it.
A Worker has three states. WORKER_STATE_UNAVAILABLE means registered and not Ready. WORKER_STATE_ACTIVE means ready for actors. WORKER_STATE_DRAINING means the pod is terminating, and a worker never leaves that state.
A terminating worker pod is slow on purpose. The controller sets its termination grace period to 3,600 seconds, and the worker gives its actors up to 30 minutes to stop. A rollout of a pool with running actors can take that long.
What kubectl ate get workers shows
We ran the command when four actors with 1 CPU each were running on the pool.
NAME POOL STATE ACTORS CPU(MILLICORES) MEMORY POD AGE
b17a6492-9643-42f1-bfdb-8bb88ee46772 ballast WORKER_STATE_ACTIVE 2/1000 2000/2000 512Mi/3008Mi lab-c/ballast-65cf95d875-bzgjd 114s
ee4ba8b4-dbfa-4555-a621-7cf7662c1091 ballast WORKER_STATE_ACTIVE 2/1000 2000/2000 512Mi/3008Mi lab-c/ballast-65cf95d875-f65dl 114sEach of the three middle columns is a pair: booked, then capacity.
| Column | Capacity comes from | In our pool |
|---|---|---|
ACTORS | the worker flag --max-actors, default 1000 | 2 of 1000 |
CPU(MILLICORES) | the CPU limit of the worker container | 2000 of 2000 |
MEMORY | the memory limit, less what the worker keeps for itself | 512Mi of 3008Mi |
The worker kept 64 MiB of its 3 GiB limit, so it reported 3008Mi. The demo pools with a 1 GiB limit reported 960Mi, the same 64 MiB less.
The booked side is the sum of the limits that each running actor declares in its ActorTemplate. Each actor here declared 1000 millicores and 256 MiB. Two actors booked the whole CPU capacity and a sixth of the memory. The worker is full, and CPU is the reason.
cpuMillicores has two jobs. The worker books it for placement, and the worker also writes it into the sandbox as a CPU quota for each 100 ms period. The documentation says that the node kernel slows the actor to that quota.
The quota is a hard cap. We tested it with a program that runs a fixed number of loop steps, about 5 seconds of work on one free core. It ran on 1 thread and on 4 threads, under four templates. We read the CPU time of the actor from its cgroup on the node, before and after each run.
We measured three runs for each row on a second x86 server guest (16 vCPU, 23 GB). The pool had one worker pod with a Kubernetes limit of 6 CPU. Raw runs
cpuMillicores | Threads | Wall time, s | Host cores used |
|---|---|---|---|
| 250 | 1 | 27.1 to 27.6 | 0.25 |
| 250 | 4 | 26.5 to 27.9 | 0.25 |
| 500 | 1 | 12.7 to 13.1 | 0.50 to 0.51 |
| 500 | 4 | 12.4 to 12.8 | 0.50 |
| 1000 | 1 | 4.9 to 5.4 | 1.01 |
| 1000 | 4 | 5.3 to 5.5 | 1.00 to 1.01 |
| 2000 | 1 | 4.1 to 4.5 | 1.12 |
| 2000 | 4 | 1.38 to 1.42 | 1.96 to 2.01 |
The actor never used more host cores than its limit. Up to 1000 millicores, four threads did not get more CPU than one thread. The same work took 5.4 times longer at 250 millicores than at 1000. In one row the limit was not the bound: one thread at 2000 millicores used 1.12 cores, because one thread fills one core. Four threads used the whole limit there.
The cap is a cgroup for each actor, inside the cgroup of the worker pod. The worker pod had cpu.max=600000 100000, which is its Kubernetes limit of 6 CPU. Inside it we found one cgroup for each actor, named with the uid of the actor and the suffix _sandbox. Its cpu.max was the template limit, and the gVisor processes of the actor ran in it.
250 25000/100000 usage_usec 41467478 nr_periods 1672 nr_throttled 1649 throttled_usec 428931388
500 50000/100000 usage_usec 38723256 nr_periods 793 nr_throttled 765 throttled_usec 157096786
1000 100000/100000 usage_usec 31895780 nr_periods 345 nr_throttled 312 throttled_usec 30529410
2000 200000/100000 usage_usec 23220151 nr_periods 204 nr_throttled 38 throttled_usec 854079 Two facts about the inside of the sandbox follow, and both can mislead you.
- The actor does not see its limit. The program counted 2 CPUs at all four limits, and it could not read the cgroup files. A runtime that sizes a thread pool from the CPU count gets 2 at 250 millicores.
- CPU time read inside a throttled sandbox is wrong. At 250 millicores with 1 thread, the program reported 22 to 24 seconds of CPU time. The same run used 6.9 seconds on the host. Do not use
getrusageor/procinside an actor to measure CPU use. Read the cgroup on the node.
The second fact explains an earlier reading of ours. A demo command reported 2.0 seconds of CPU time in 2.38 seconds under 500 millicores, which looked like no cap. That CPU time came from inside the sandbox, so it does not count. A tool sandbox has that run and the correction.
We tested one actor at a time. We did not test memory limits, or CPU limits with several busy actors on one worker. One row is not explained. Four threads at 2000 millicores used 2.8 seconds of host CPU, and 1 thread used 4.6 to 5.1 seconds for the same work.
A suspended actor books nothing. The command counts running actors only.
Two habits from kubectl do not carry over. The command has no -A flag, and it lists the workers of every namespace by default. It has four filters: -n for one Kubernetes namespace, -l for pool labels, -a for the workers that host actors of one atespace, and --sandbox-class.
A pool without limits reports the whole node
kagent v1.0.0 installs a pool named kagent-default with no resources at all. We listed its worker on a guest with 16 vCPUs and 31 GiB.
NAME POOL STATE ACTORS CPU(MILLICORES) MEMORY POD AGE
6dfb9d1d-cce7-4e42-af53-799a4132fa5d kagent-default WORKER_STATE_ACTIVE 0/1000 0/16000 0/32797744Ki kagent/kagent-default-679c58c6cb-pljdk 19mThe worker claims all 16 CPUs and all the memory of the node. The Substrate documentation says that a worker without limits reports zero and has no bound. Our run disagrees: the worker read the size of the node and reported that. A user reported the same on a larger cluster.
The result is a capacity that nothing backs. The pod has no limit, so Kubernetes reserves nothing for it. A second pool on the same node would claim the same 16 CPUs again. The control plane, the dataplane and Postgres run on that node too. kagent 1.0 on Substrate shows what that worker did under load.
Our position: always set limits on a pool, and set the memory request equal to the memory limit. Then the memory that the worker reports is memory that Kubernetes has reserved. Kubernetes reserves CPU only up to the CPU request. The example above requests 500 millicores and reports 2 CPUs. Set the CPU request equal to the limit when actors must get the CPU that they book. Sizing a pool turns that rule into numbers.
One more fact matters before you give a pool to a team. A worker pod runs as root with SYS_ADMIN and NET_ADMIN, and its seccomp profile is unconfined. The sandbox inside the pod isolates the actor. The pod does not isolate the worker from the node, so run pools on nodes that you trust.
Sandbox classes: gVisor and microVM
A pool runs one sandbox class, and an actor stays on the class of its first snapshot for the rest of its life.
A security reviewer asks one question about your agent platform: is each agent in a container or in a virtual machine? With Substrate the honest answer is neither one, and the choice is yours. An actor runs in a gVisor sandbox or in a small virtual machine. The choice is a field of the WorkerPool, and it is hard to change later.
When you finish this section, you can choose a class for a pool and check that a node can run it. You can also name the behaviours that differ between the two classes.
Most measurements in this manual come from the gvisor class. On the x86 server guest we also ran one test actor on both classes, at 0, 200, and 500 MiB of memory. This section reports that comparison, and it names what stays untested.
Two classes, one field
Sandbox class: the kind of isolation that the workers of a pool give to each actor. The value is gvisor or microvm.
You set the class in spec.sandboxClasses[0].name of the WorkerPool. The set is closed. The API accepts only those two names, and a plain Linux container is not an option.
| Area | gvisor | microvm |
|---|---|---|
| Isolation | runsc, a kernel in user space | a KVM guest: Kata Containers on Cloud Hypervisor |
| Node needs | any Linux node | a node with /dev/kvm |
| CPUs that the actor sees | at least 2, whatever the limit | the limit, rounded up, at least 1 |
| Smallest memory limit | none | 128 MiB |
| Limits for each container | accepted and ignored | enforced in the guest |
| Files in a snapshot | runsc checkpoint bundles | tar archives |
What the node must provide
A gVisor worker runs on any Linux node. That is why it runs on a laptop.
A microVM worker needs the device /dev/kvm. atelet offers the device to Kubernetes as the resource ate.dev/kvm, only on nodes that have it. The controller adds a request for that resource to every microVM worker pod. So a microVM pool on a cluster without KVM does not fail loudly. Its pods stay Pending.
The script that creates the kind cluster tells you which case you are in. On the Apple silicon laptop under Docker Desktop it printed this:
Probing for /dev/kvm in the Docker environment...
/dev/kvm not available: micro-VM support disabled (gVisor still works).On the x86 server guest, which has nested KVM, the same script printed the other answer:
Probing for /dev/kvm in the Docker environment...
/dev/kvm found: micro-VM (kata + cloud-hypervisor) support will be enabled.On that guest the install needed no further step for microvm. The kind script had passed /dev/kvm to the node, and the installer had created the SandboxConfig.
So on a Mac under Docker Desktop you have the gvisor class only. On a cloud VM, the instance type must allow nested virtualization. Check the node type before you plan for microvm.
The installer prepares both classes by default. It creates one SandboxConfig for each class and loads the guest kernel and the other microVM files into the object store. Pass --sandbox-classes=gvisor to skip the microVM part.
How the limits apply
The ActorTemplate declares one CPU limit and one memory limit for the actor. The two classes turn them into different things.
CPU. A gVisor actor with a limit of 500 or 1000 millicores sees 2 CPUs, and the node kernel slows it to its quota. A microVM actor with 500 millicores starts with 1 virtual CPU and gets half a CPU of node time. A program that sizes its thread pool from the CPU count behaves differently on each class.
Memory. On gvisor the limit is the memory that the sandbox can use. On microvm the limit is the RAM of the guest, and the virtual machine monitor needs more memory on top. The worker counts that overhead against its capacity, so the same pool holds fewer microVM actors than the limits alone suggest. In our run the overhead was 7.6 MiB for an actor with a limit of 456 MiB.
Containers. An actor can have more than one container. On gvisor one Sentry process runs all of them, and a limit on one container has no effect. On microvm each container has its own cgroup in the guest. A container that passes its memory limit is killed alone, and the other containers of the actor continue.
That last behaviour has a cost that others reported. In issue 3615, a container in a microVM actor was killed for memory, and the actor stayed ACTOR_STATE_RUNNING with no log line. Actor and its seven states shows the same silence for a process that exits on gvisor, which we did measure.
A snapshot pins the class
The class is not only a property of the pool. The first snapshot of an actor records it, and every later restore of that actor needs a worker of the same class. This is the snapshot record of our counter actor after a synchronous suspend.
fidelity: SNAPSHOT_FIDELITY_MEMORY
generation: 3
locality: kind-control-plane
owner: SNAPSHOT_OWNER_ACTOR
sandboxRuntime:
sandboxClass: gvisor
version:
attributes:
- key: architecture
value: amd64
- key: cpu_features
value: 8c0000002e500800008003f70000000f00400004219c01ab178bfbfffff83203
- key: sandbox_bundle_hash
value: sha256:88e9809119d7fa2204897a60f8bb155add102018b4b4947e7876c9bc447312faA microVM snapshot records different attributes. This is the same part of the record for the counter actor on a microVM pool.
fidelity: SNAPSHOT_FIDELITY_MEMORY
generation: 5
locality: kind-control-plane
owner: SNAPSHOT_OWNER_ACTOR
sandboxRuntime:
sandboxClass: microvm
version:
attributes:
- key: architecture
value: amd64
- key: cpu_model
value: "23:49"
- key: cpu_vendor
value: AuthenticAMD
- key: sandbox_bundle_hash
value: sha256:1d40aaaf66896731f227d4cfe129ab724721b592ff89c73df603e8e2372a67b8A gVisor snapshot names the CPU features as a bit string. A microVM snapshot names the CPU vendor and one CPU model. Three consequences follow from those records:
- You cannot move an actor to the other class. A template that selects pools of both classes still gives each actor one class, the class of its golden snapshot.
- A memory snapshot is tied to more than the class. It records the CPU architecture, the CPU features or the CPU model, and the exact sandbox build. A worker restores memory only when its runtime is compatible.
- The result of a mismatch is a cold start. When no compatible worker has room, Substrate takes a snapshot from the object store and restores only its files. It uses another worker of the same class. The processes start again, and the actor loses what it held in memory.
The third point is the one to plan for. A pool that mixes node types with different CPUs, or a rollout of a new sandbox build, can turn a resume into a cold start. Placement shows where that decision is made.
What others reported about parity
The two classes do not behave the same at v1.0.0, and the project tracks the gaps in issue 3226. These reports come from the issue tracker. We ran none of them.
| Report | Class | What was seen |
|---|---|---|
| Issue 3615 | microvm | a container killed for memory leaves the actor RUNNING, with no log |
| Issue 3720 | microvm | file writes count against the memory limit, and an actor that wrote 2 GB was killed |
| Issue 3801 | microvm | restore runs under the CPU limit of the actor, so a small limit slows the resume |
| Issue 3153 | microvm | every small file operation crosses from the guest to the node, which slows work with many files |
| Issue 3408 | gvisor | at about 6,000 actors on one node, sandboxes died while their actors stayed RUNNING |
The two classes, measured
We ran the test actor of SuspendActor and ResumeActor on a microVM pool. The machine, the node, and the actor image were the ones behind the gVisor numbers. The actor holds a fixed amount of random memory.
| Memory held | Phase | microvm | gvisor |
|---|---|---|---|
| 0 MiB | first call of a new actor | 0.45 s | 0.23 s |
| 0 MiB | resume from the node | 0.33 s | 0.22 s |
| 0 MiB | suspend, asynchronous | 0.39 s | 0.34 s |
| 0 MiB | suspend, synchronous | 0.72 s | 0.40 s |
| 200 MiB | first call of a new actor | 0.79 s | 0.62 s |
| 200 MiB | resume from the node | 0.49 s | 0.47 s |
| 200 MiB | suspend, asynchronous | 0.80 s | 0.79 s |
| 200 MiB | suspend, synchronous | 1.89 s | 2.02 s |
| 500 MiB | first call of a new actor | 1.39 s | 5.76 s |
| 500 MiB | resume from the node | 0.80 s | 1.07 s |
| 500 MiB | suspend, asynchronous | 1.17 s | 1.17 s |
| 500 MiB | suspend, synchronous | 3.35 s | 3.59 s |
We measured both columns on the x86 server guest (24 vCPU, 47 GB, nested KVM), with one kind node and the Envoy dataplane. Each cell is a median. Each size had 5 first calls, 10 resumes, 10 asynchronous suspends, and 5 synchronous suspends. The first call and the resume are timed at the client, through kubectl ate proxy. All 90 requests of the microVM run returned 200. A request to a running actor took 0.065 seconds on microvm and 0.064 seconds on gvisor. Raw runs
The table has four results:
- A small actor pays a fixed cost on
microvm. At 0 MiB the resume was 0.10 seconds slower and the first call was 0.22 seconds slower. The restore starts a guest with its own kernel before the actor runs. - Memory costs less on
microvm. From 0 to 500 MiB, a resume grew by about 1 ms for each MiB onmicrovmand by about 2 ms ongvisor. The medians were 0.02 seconds apart at 200 MiB. Above that sizemicrovmwas the faster class. - A large new actor starts much faster on
microvm. At 500 MiB the five first calls took 1.26 to 2.96 seconds onmicrovmand 1.86 to 6.76 seconds ongvisor. - An asynchronous suspend costs the same. The medians differ by 0.06 seconds or less at every size. A synchronous suspend of a small actor is slower on
microvm, because its snapshot is larger.
What one actor costs on the node
| Item | microvm | gvisor |
|---|---|---|
| Processes on the node for one actor | one cloud-hypervisor and two virtiofsd | one runsc-sandbox and one runsc-gofer |
| Resident memory with 200 MiB held | 280 MiB | 237 MiB and 11 MiB |
| Snapshot on the node, 0 MiB held | 58.6 MiB | 1.3 MiB |
| Snapshot on the node, 200 MiB held | 264.0 MiB | 201.9 MiB |
| Snapshot on the node, 500 MiB held | 569.7 MiB | 502.2 MiB |
| Files of a memory snapshot | memory-ranges, state.json, config.json, one tar archive for each container | pages.img, checkpoint.img, pages_meta.img, a rootfs directory |
| Memory booked on the worker | 463.6 MiB for a limit of 456 MiB | 250 MiB for a limit of 250 MiB |
We measured these values on the same x86 server guest, with one sample for each cell. Resident memory is the value that ps printed on the node. The snapshot size is the disk use of the snapshot directory of one actor. The gVisor booking is from the pool of the second x86 server guest. Raw runs
A microVM snapshot holds the whole guest, with its kernel and its agent. It was 59 to 70 MiB larger than the memory that the actor held. Add that amount for each actor when you size the node disk and the bucket in sizing a pool. The file memory-ranges is sparse. Its listed size is the RAM of the guest, and its disk use is what the guest touched.
The clock inside the actor
The test actor reports the age of its process in every reply. The two classes count that age differently.
We measured three suspend and resume cycles of the 0 MiB actor on each class, on the same guest. One cycle took 1.6 to 1.8 seconds. On microvm the age grew by 1.8 or 1.9 seconds in each cycle: 28.4, 30.3, and 32.1 seconds. On gvisor it grew by 0.2 or 0.3 seconds in each cycle: 0.5, 0.8, and 1.0 seconds. Raw runs
So a microVM actor sees the time that passed while it was suspended. A gVisor actor sees only the time that it ran. A timeout, a cache lifetime, or a token check inside the actor gives a different result on each class. We read the elapsed-time clock of the process only, and we did not compare the calendar time.
What stays untested
All of these numbers come from one x86 machine with one CPU model. We did not run microvm in these cases:
- on arm64
- with the agentgateway dataplane
- with a resume from the object store
- with more than 500 MiB held
- on a pool whose nodes have different CPU models
- with the loss of a node
The reports from the issue tracker in the table above also stay reports. We ran none of them.
Our position
Choose by the memory that your actors hold. With small actors, start with gvisor. It runs on every node, it resumed a small actor 0.10 seconds faster, and its snapshot is as large as the memory and no larger. It is also the class that kagent installs.
Choose microvm in three cases. A reviewer requires hardware virtualization between tenants. You need a limit for each container. Or your actors hold more than about 200 MiB. In the third case microvm resumed as fast or faster on our machine, and a new 500 MiB actor started four times faster. You pay with a node type that has KVM and with 59 to 70 MiB of extra snapshot for each actor.
Then test your own workload. The reports above say that the two classes differ in the cases that hurt: memory pressure and many small files.
Neither class makes the worker pod a boundary. Both worker types run as root with SYS_ADMIN and NET_ADMIN, and the threat model of Substrate rates the two classes as equal.
Placement: which worker gets the actor
ate-api-server picks a worker at every resume: it filters the workers, takes two at random, and binds the actor to the less loaded one.
Your actor ran on one worker pod this morning and on another one after lunch. Or a request fails with no worker has room for the actor while kubectl ate get workers shows a worker with free CPU. Both cases have the same cause. Substrate places an actor again each time it resumes, and the rules are not the rules of the Kubernetes scheduler.
When you finish this section, you can list the filters that a worker must pass and predict where an actor resumes. You can also explain a no worker has room error on a pool that is not full.
Most of our runs had one node. For this section we also built a cluster with one control plane node and two worker nodes, and we stopped one worker node four times. One result is against what the code suggested, and the section says where.
Two schedulers, two jobs
Kubernetes places worker pods on nodes, once, when the pool starts. It never sees an actor.
Substrate places actors on workers. The scheduler is a function inside ate-api-server. It runs in the ResumeActor call, and it reads the workers from memory and from Postgres, not from the Kubernetes API. That is why placement continued in our test with the Kubernetes API server down, as when a dependency is down shows.
A new actor has no worker. CreateActor writes a row with the state ACTOR_STATE_SUSPENDED, and placement waits for the first request. A suspend releases the worker again.
Placement: the choice of one worker for one actor, made at each resume.
The filters
A worker is a candidate when all of these are true:
- It is registered, and its state is
WORKER_STATE_ACTIVE. - Its sandbox class is the class of the snapshot that the actor resumes from.
- It is on the node that holds the local snapshot of the actor, if a local snapshot exists.
- Its runtime can restore the memory snapshot, if the snapshot has memory.
- The labels of its pool match the
workerSelectorof the template and of the actor. - It has room: one free actor place, and free CPU and memory for the booking of the actor.
With no candidate, the call fails with no worker has room for the actor. The text is the same whichever filter removed the last worker. So the error does not always mean that the pool is full. It can mean that the only workers with room are of the wrong class or without the right label.
With one candidate, that worker gets the actor. With more, the scheduler takes two at random and keeps the less loaded one. The load of a worker is its fullest dimension: actor places, CPU, or memory.
The bind is one Postgres transaction. It locks the row of the worker, checks the room again, and writes the assignment. Two resumes that compete for the last place cannot both win. The loser tries again with another worker.
What our runs showed
We created four actors on a pool of two workers and sent each one a request, one after the other.
ATESPACE NAME TEMPLATE STATE WORKER POD WORKER IPS VERSION AGE
lab-c b0-1 lab-c/ballast-0 ACTOR_STATE_RUNNING lab-c/ballast-65cf95d875-bzgjd 10.244.0.26 43 69s
lab-c b0-2 lab-c/ballast-0 ACTOR_STATE_RUNNING lab-c/ballast-65cf95d875-f65dl 10.244.0.27 3 68s
lab-c b0-3 lab-c/ballast-0 ACTOR_STATE_RUNNING lab-c/ballast-65cf95d875-bzgjd 10.244.0.26 3 66s
lab-c b0-4 lab-c/ballast-0 ACTOR_STATE_RUNNING lab-c/ballast-65cf95d875-f65dl 10.244.0.27 3 65s
lab-c b0-5 lab-c/ballast-0 ACTOR_STATE_SUSPENDED <none> 1 63sThe actors alternate between the two pods. With two candidates, the scheduler always compares both, and the emptier one wins. The fifth actor found no candidate and stayed suspended.
The parking run shows the other half of the rule. Actor p3 waited for a worker. When we suspended p1, p3 resumed on the pod that p1 had used. An actor has no home worker. It goes wherever a place is free at that moment.
Across two nodes
The scheduler compares workers, not nodes. On the cluster with two worker nodes, the pool had four workers, two on each node. We created 20 actors, sent each one a request, and left all of them running.
node multi-worker 10
node multi-worker2 10
worker lab-n/ballast-5d6cf97747-xlw7d 6
worker lab-n/ballast-5d6cf97747-dpjlc 5
worker lab-n/ballast-5d6cf97747-5cmpq 5
worker lab-n/ballast-5d6cf97747-67bkm 4We measured this spread on the second x86 server guest (16 vCPU, 23 GB), with one run of 20 actors on gVisor. Ten actors went to each node, and the four workers got 6, 5, 5, and 4. One create and one resume through the CLI took 0.95 to 1.19 seconds, with a median of 1.00 seconds. Raw runs
The node of the last suspend
Filter 3 is the one that surprises people. After an asynchronous suspend, the snapshot is on the disk of one node, and the actor remembers that node.
assignedNode: kind-control-plane
lastAssignedGeneration: 2While assignedNode is set, only workers on that node are candidates. The reason is speed: a resume from the local disk skips the download from the object store. In our runs the node kept the actor for 30 minutes after the suspend, the default of the atelet flag --snapshot-detach-delay. After that the field was empty and any node could take the actor. Snapshot fidelity, and where a snapshot lives has the measured timeline.
On the cluster with two worker nodes, the rule held in every resume.
We measured one actor on the second x86 server guest (16 vCPU, 23 GB), with five asynchronous suspends and one request after each. All 5 resumes went to the node of the suspend. The worker pod changed between the two workers of that node. Then we ran kubectl cordon on the node. The next 2 resumes still went to that node, in 0.41 and 0.29 seconds. Raw runs
2026-11-12T12:02:26.555Z cordon multi-worker2
2026-11-12T12:02:26.986Z resume-call-while-cordoned 200 0.408920 count=8 mb=50 sum=6613 process_age_s=8.2
2026-11-12T12:02:27.433Z resumed-on multi-worker2 pod lab-n/ballast-5d6cf97747-xlw7d
2026-11-12T12:02:28.977Z suspended-sync ACTOR_STATE_SUSPENDED multi-worker2 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen8:multi-worker2
2026-11-12T12:02:29.292Z resume-call-while-cordoned 200 0.293103 count=9 mb=50 sum=6613 process_age_s=9.0
2026-11-12T12:02:29.727Z resumed-on multi-worker2 pod lab-n/ballast-5d6cf97747-xlw7dA cordon is a Kubernetes mark for new pods, and the Substrate scheduler does not read it. So a cordon does not keep a suspended actor away from its node. The fourth line shows one more fact. After a synchronous suspend the snapshot is complete, and the actor still names the node.
The rule has a cost when the node is full while another node has free workers. The code handles that case in the same resume call. ate-api-server asks atelet on that node to upload the snapshot and delete the local copy, and then it tries the other nodes.
We measured that path once, on the second x86 server guest (16 vCPU, 23 GB), with both worker nodes up. We filled all four workers, and a request to the suspended actor got 503 after 5.05 s, in 2 of 2 requests. The body was no worker has room for the actor, and the ate-api-server log names the step DetachFromFullNode. We then freed one worker on the other node, which did not hold the snapshot. The next request returned 200 in 0.61 s on that node, and the count of the actor continued. Raw runs
A drain takes the same path. After kubectl drain, the node had no worker, and its atelet still ran. Three suspended actors resumed on the other node at their first request, in 0.49 to 1.19 s. When a dependency is down has the five drain cases.
When the node stops
Our reading of the code said that an actor with a durable snapshot forgets a node that is down and resumes on another node. Our runs did not show that. We stopped one worker node four times, with the actor in a different condition each time. The other node had two active workers with room in every run.
First, what Substrate knows about the node.
We measured the detection on the second x86 server guest (16 vCPU, 23 GB), in 4 of 4 stops. The workers of the stopped node were listed WORKER_STATE_ACTIVE for 43 to 51 seconds after the stop command. Then they were marked WORKER_STATE_UNAVAILABLE, at the time that Kubernetes reported the node NotReady. Kubernetes evicted the worker pods 5 minutes later, and the Deployment created two new pods on the other node. Raw runs
{"time":"2026-11-12T12:03:19.231654833Z","level":"INFO","msg":"Syncer: marking worker unavailable (pod not Ready)","worker":"090b9723-a53d-4e0c-a139-741942971a78","pod":"lab-n/ballast-5d6cf97747-67bkm"}The reason in that line is a pod that is not Ready. Substrate learned of the dead node from Kubernetes, and in our runs nothing in Substrate reported it sooner. For those 43 to 51 seconds, the scheduler can give a new resume to a worker on a node that is gone.
Second, what happened to the actor in each of the four runs.
| The actor when the node stopped | What the caller saw | The actor state |
|---|---|---|
Suspended with --mode sync, snapshot COMPLETED, then a request every few seconds | 504 after 10 s, when Envoy stopped its wait for the router, then 503 actor lab-n/u1 unavailable after 5 to 8 s. From 5 min 44 s after the stop command, 503 at once with AssignWorker prerequisite not met | RESUMING on the stopped node from the first request, then CRASHED |
The same, with a revert 31 s after the stop command and before any request | The revert returned SUSPENDED with no node. The first request then bound the actor to a worker of the stopped node. It got 504 after 10 s, when Envoy stopped its wait, and each of the next 19 requests got 503 | RESUMING on the stopped node for the 2.5 min that we probed |
Suspended in the default mode, snapshot PENDING, requests for 2 min and then none | 504, then 503. A revert after the first request failed: another operation is in progress for this actor | Still RESUMING 10 min 42 s after the stop command, with no request in the last 8 min 43 s. A second revert failed: not in a revertable state. The actor was CRASHED at the next request, 0.6 s after that second attempt. The trace cannot say which of the two caused the change |
RUNNING on the node | 503 for each of 150 requests: upstream connect error or disconnect/reset before headers. A revert failed: while calling atelet Terminate | RUNNING for the 10 min that we probed, then REVERTING after the failed revert |
2026-11-12T12:02:36.103Z suspended-sync ACTOR_STATE_SUSPENDED multi-worker2 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:multi-worker2
2026-11-12T12:02:36.563Z node-stop docker stop multi-worker2
2026-11-12T12:02:46.908Z node-stopped multi-worker2
2026-11-12T12:02:57.202Z probe 504 10.055616 | ACTOR_STATE_RESUMING multi-worker2 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:multi-worker2
2026-11-12T12:03:07.214Z probe 503 7.777073 actor lab-n/u1 unavailable | ACTOR_STATE_RESUMING multi-worker2 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:multi-worker2
…
2026-11-12T12:08:14.892Z probe 503 5.051588 actor lab-n/u1 unavailable | ACTOR_STATE_RESUMING multi-worker2 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:multi-worker2
2026-11-12T12:08:20.312Z probe 503 3.190662 actor lab-n/u1 unavailable: AssignWorker prerequisite not met for Actor: lab-n/u1 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED) | ACTOR_STATE_CRASHED multi-worker2 SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:multi-worker2Our client allowed 12 s for each of these requests. So the 504 at 10.06 s came from the dataplane, at the limit that section 4.4 describes. We first read it as our own timeout.
A suspended actor did not move to the other node. This is the result that is against our reading of the code. In the first run the snapshot was complete in the object store, and the other node had two free workers. The actor still stayed bound to the stopped node. It was in ACTOR_STATE_RESUMING for 5 min 33 s from its first request, and it was ACTOR_STATE_CRASHED 5 min 44 s after the stop command. Substrate v1.0.0 did not move an actor off a dead node by itself in any of our runs.
A revert did not free the actor either, in 4 of 4 attempts in three runs. In the second run it came too early. The workers of the stopped node were still listed as active, and the next request chose one of them. In the third run the resume that was in progress blocked both attempts. In the fourth run the revert needed atelet on the stopped node, and the actor stayed in ACTOR_STATE_REVERTING.
Those four runs left one question: does a revert help when you send it after the workers are marked unavailable? A second set of 13 node-stop cases answered it. Yes, for one kind of actor only.
We measured each case once, on a second x86 server guest (16 vCPU, 23 GB). The cluster had two worker nodes, the Envoy dataplane, and the default timers. In the first case, an actor was suspended with --mode sync and got no request after the stop. The workers of the node showed WORKER_STATE_UNAVAILABLE at 45 s. One revert then took 7.1 s and returned the actor with no node. The next request resumed it on the other node in 0.56 s, and the count continued from the snapshot. Raw runs
2026-11-12T12:53:34.330Z t=0.7 node-stopped multi-worker2
2026-11-12T12:54:18.228Z t=44.6 workers-of-stopped-node-not-active 2 WORKER_STATE_ACTIVE 2 WORKER_STATE_UNAVAILABLE
2026-11-12T12:54:25.821Z t=52.1 revert 7.1 s | ATESPACE NAME TEMPLATE STATE WORKER POD WORKER IPS VERSION AGE lab-n a1 lab-n/ballast-50 ACTOR_STATE_SUSPENDED <none> 8 56s
2026-11-12T12:54:26.821Z t=53.1 first-request 200 0.560088 count=4 mb=50 sum=6613 process_age_s=4.9 | ACTOR_STATE_RUNNING multi-worker SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:-The condition is narrow. The same kind of actor, reverted 10 s after the stop command, went to ACTOR_STATE_REVERTING and stayed there until the node was back. An actor that had got a request was refused 19 of 19 times while it was ACTOR_STATE_RESUMING. An actor with a PENDING snapshot, and a RUNNING actor, went to ACTOR_STATE_REVERTING at the first revert. In all of these cases the revert calls atelet on the stopped node, and that call fails.
One path worked in every state: a tag that exists before the node stops. A new actor from the tag served its first request on the other node 1.2 s after the create command, in 3 of 3 rescues. Section 8.2 has the runbook for each case, and figure 8.2d puts it on one page. Without a tag, treat a stopped node as an outage for every actor that got a request.
A RUNNING actor on a dead node is the worst case to see. Its state did not change in 10 minutes, so a list of actors shows nothing wrong. CRASHED and RevertActor has the error texts for each case.
When the node returned, it was Ready within seconds. It had none of the four pool pods in three of the four returns, because the new pods ran on the other node. Kubernetes does not move them back, and no new actor went to the returned node for more than 3 minutes. After we deleted two pool pods by hand, the first actor went to the returned node 23 to 35 seconds after the node start.
The second set of cases gave the same split, with more returns. After an outage shorter than the eviction time, the node kept its pool pods. The first new actor went to it 23 to 45 seconds after the start, in five returns. After a longer outage the node had no pool pod, in seven of seven returns. No new actor went to it in the 216 to 223 seconds that we watched. Substrate does not rebalance a pool.
We measured the case where the other node has no room, in two runs on the same guest. The revert was accepted, and each request got 503 after 5 s with no worker has room for the actor, in 8 of 8 requests. The actor stayed ACTOR_STATE_SUSPENDED. It answered 1.6 s after we freed a worker in one run, and 1.7 s after we scaled the pool in the other. When a dependency is down has both runs. Raw runs
We did not test a pool with spare workers on a third node, or a node loss with the microVM class. We also did not test a node that is alive and unreachable.
After a node restart
A restart of the node removes every worker at one time. We did that twice on the single node of the x86 server guest. The first run sent one request after the pods were back. The second run tried one resume every 2 to 3 seconds and listed the workers each time.
We measured the first recovery on the x86 server guest (24 vCPU, 47 GB, nested KVM), one run. The Kubernetes API answered again after 8 seconds, and all pods were running after 147 seconds. A request for a suspended actor then returned 503 with no worker has room for the actor. The same request returned 200 about 80 seconds later. Raw runs
The second run shows why.
| Seconds from the restart command | What we saw |
|---|---|
| 0 to 10 | The restart command runs. |
| 10 to 157 | kubectl ate fails with no ready api pods in ate-system. Kubernetes reports the node Ready from 18 s. |
| 160 | ate-api-server answers for the first time. It lists all 11 workers as WORKER_STATE_ACTIVE. A resume fails with while calling atelet Restore. |
| 163 to 171 | Each worker from before the restart is marked WORKER_STATE_UNAVAILABLE. A resume fails, because the worker assigned to the actor is unavailable. |
| 174 | The first resume that works. |
| 178 to 191 | The workers return to WORKER_STATE_ACTIVE one by one. At 191 s, 11 of 11 are active. |
We measured this timeline on the x86 server guest (24 vCPU, 47 GB, nested KVM), with one restart and one suspended actor. Each poll made one resume attempt. Raw runs
The mechanism has three steps. When the control plane returns, it first serves the worker list that it had before the restart. Then it marks each of those workers unavailable. Each worker becomes active again when it registers through atelet, as WorkerPool describes. Between the second and the third step, no worker of the pool is a candidate.
A resume in that window fails for lack of room or for an unavailable worker. The window was 10 to 30 seconds in this run. In the first run our one retry came 80 seconds later, so the window looked longer than it was.
The long part of the recovery is the control plane, not the workers. ate-api-server needed 160 of the 191 seconds.
Plan for this after a node restart: no call works until ate-api-server answers, and resumes can fail for about 30 seconds more. Do not use a check that all pods are Running as the signal. Kubernetes showed the old pod status at first: 0 pods not running at 10 seconds, and 21 at 18 seconds.
An actor that was running on a node that restarts is ACTOR_STATE_CRASHED, and CRASHED and RevertActor covers it. A node that stays down is different, as the table above shows. We did not repeat the restart on a cluster where the control plane and the workers are on different nodes.
Steer placement with labels
You have two tools, and both are labels:
- Pool labels and selectors. Put a label on the WorkerPool object, and select it in the template with
workerSelector.matchLabels. An actor can add its own selector, and both must match. - Node rules for the pool.
spec.template.nodeSelector,tolerationsandnodeAffinityof the pool decide which nodes the worker pods use. They act on Kubernetes, before any actor exists.
There is no way to pin one actor to one worker, and no priority between actors. A template can select pools with different CPU types, and that is unwise. A memory snapshot from one type cannot always restore on the other.
Request parking
When no worker has room, the router holds a request for up to 5 seconds and retries the resume before it answers 503.
Twenty users open a session in the same second, and your pool has room for sixteen actors. Four requests have no worker. You need to know if those four users see an error at once, a delay, or an error after a delay. The answer is all three, and the router decides which.
When you finish this section, you can predict what a client sees when a pool is full. You can also tell the two capacity 503 texts apart and choose a client timeout.
What the router does when a pool is full
Every request to an actor passes through atenet-router, and the router calls ResumeActor for it. If the actor is suspended, ate-api-server must find a worker with room. When no worker has room, the call fails with the gRPC code ResourceExhausted and the text no worker has room for the actor.
Parked request: a request that the router holds open while it retries the resume of the actor.
The router does not return that failure at once. It parks the request and tries again. Five rules decide what happens next:
- Retry. The first retry comes after 100 ms. Each later delay is 1.1 times longer, with a small random part.
- Budget. The router stops retrying after 5 seconds, the value of
--parked-request-budget. Then the client gets the 503. - One budget for each actor. Requests for the same actor share one resume call and one budget clock. A request that joins late can fail after less than 5 seconds.
- Limit. At most 1,024 requests can be parked at one time, the value of
--parked-request-max. A request takes a place only when its first resume attempt has failed and it starts to wait. When no place is free, the router sheds the request after that one attempt, with a 503. The body isactor "<atespace/name>" unavailable: router at capacity. - No cancel. A resume that has started when the budget ends is not cancelled. The router waits for its result, so a request can be served after more than 5 seconds.
Set --parked-request-max=0 to turn parking off. Then a full pool returns the 503 at once.
The install sets none of these flags, so the defaults apply. The arguments of our router show it.
atenet-router router --mode=ingress --namespace=ate-system … --drain-delay=13s --ingress-allowed-spiffe-ids=spiffe://cluster.local/ns/ate-system/sa/atenet-client --preview=What the router parks, and what it does not
Parking is for conditions that can end without help. The router returns every other error at once.
| Result of the resume | What the router does | What the client sees |
|---|---|---|
| the actor is running | routes the request | the answer of the actor |
| no worker has room | parks and retries | the answer, or 503 after the budget |
ate-api-server is unavailable | parks and retries | the answer, or 503 after the budget |
| another resume of the same actor is active | retries | the answer |
the actor is ACTOR_STATE_CRASHED | returns the error | 503 at once |
| the actor does not exist | returns the error | 404 at once |
The third row matters during a rollout of ate-api-server. A short restart of the control plane becomes a delay for the client and not an error. When a dependency is down shows the limit of that protection.
The fifth row is why a 503 after 5 seconds and a 503 after 60 ms are different problems. A slow 503 with the text no worker has room means the pool is too small. A slow 503 with only the word unavailable means the router got no answer from ate-api-server. A fast 503 with the text AssignWorker prerequisite not met means the actor is crashed, and CRASHED and RevertActor has the fix.
One request, three outcomes
The parking demo of Substrate creates a pool of two workers with room for one actor each, and four actors named p1 to p4. We sent one request each to p1 and p2, so both workers were busy. Then we sent a request to p3 two times.
2026-11-12T11:32:40.522Z p3-no-free-worker 503 5.059628 actor ate-demo-parking/p3 unavailable: no worker has room for the actor
2026-11-12T11:32:45.600Z p3-request-start parked while pool is full
2026-11-12T11:32:47.970Z suspend-p1-done freed a worker 2 s after p3 request started
2026-11-12T11:32:45.603Z p3-served-after-park 200 2.647469 preserved memory count: 1 | preserved file counter: -1 | preserved rootfs counter: 1The first request to p3 found no worker for the whole budget. The second one started the same way. Two seconds into its wait we suspended p1, and that freed a worker. The next retry of the router found the worker, p3 resumed there, and the client got its answer.
We measured the wait on a full pool on the x86 server guest (24 vCPU, 47 GB, nested KVM, Envoy dataplane). Nine requests that found no worker each returned 503 after 5.05 to 5.06 seconds. The request that we freed a worker for returned 200 after 2.65 seconds. The suspend of p1 finished 2.37 seconds into that wait. The router then needed about 0.3 seconds to retry, resume the actor, and route the request. That last number comes from one run. Raw runs
The body of the slow 503 is always the same text, with the atespace and the name of the actor in it:
actor lab-c/b0-5 unavailable: no worker has room for the actorThirty seconds with four actors on two workers
The demo has a load script. It sends requests to all four actors for 30 seconds and suspends each actor after its answer. Two workers serve four actors, so half of the requests must wait for a suspend.
==> results
total requests : 92
200 OK : 90
503 unavailable: 2
other : 0
200 latency : avg 0.847s, slowest 4.269406s <- parked requests sit hereWe measured this load run on the same x86 server guest, one run of 30 seconds, 92 requests. The router served 90 requests and returned 503 for 2. The served requests took 0.85 seconds on average, and the slowest took 4.27 seconds. A request to a free worker in the same pool took about 0.35 seconds. Raw runs
Read those numbers together. Parking turned a pool that was two times too small into a service that answered 98% of requests. It did that with latency. The average request took more than two times longer than a request to a free worker. The slowest took twelve times longer. Two requests still failed, in a demo that was built to show parking at its best.
A burst of resumes
The demo sends its requests one after another. A real burst arrives in one instant, so we tested that. The pool had two workers with room for 16 one-CPU actors. We suspended n actors of 50 MiB each and sent one request to each of them at the same instant, five rounds for each n.
| Resumes at the same instant | Requests | Returned 200 | Median | Slowest |
|---|---|---|---|---|
| 1 | 5 | 5 | 0.23 s | 0.24 s |
| 2 | 10 | 10 | 0.23 s | 0.24 s |
| 4 | 20 | 20 | 0.26 s | 0.31 s |
| 8 | 40 | 40 | 0.29 s | 0.33 s |
| 16 | 80 | 80 | 0.37 s | 0.45 s |
We measured these 155 requests on the x86 server guest (24 vCPU, 47 GB, nested KVM), with gVisor and the Envoy dataplane. Each request went through its own kubectl ate proxy, and the time is at the client. All 155 returned 200. Sixteen resumes at once cost 64 percent more for each request than one resume alone. ate-api-server logged no error line during the bursts. Raw runs
So the control plane did not queue at 16 resumes at once. A burst that fits in the pool costs tenths of a second, and no request waits for the budget.
A burst that does not fit is split by the pool, with no request in between. We sent 32 requests at the same instant to 32 suspended actors, when the pool had 14 free places.
We measured that burst once, on the same guest. Fourteen requests returned 200, at a median of 0.37 seconds. The other 18 returned 503 after 5.11 to 5.15 seconds, with the text no worker has room for the actor. In a second burst of 32 we suspended 16 actors 1.5 seconds into the wait. Then 21 of 32 requests returned 200, the slowest after 2.69 seconds, and 11 returned 503. Raw runs
The pool had 14 free places and not 16 for a reason that section 3.5 explains. Two actors whose process had exited were still ACTOR_STATE_RUNNING, and each one held a CPU.
A resume that takes more than 10 seconds
The parking budget is not the only clock. Envoy waits a fixed time for the router to decide where a request goes. The code sets that time to the parking budget plus 5 seconds. With the default budget that is 10 seconds. A resume that takes longer gets a 504 from Envoy, and the resume is not cancelled.
We measured this on a second x86 server guest (16 vCPU, 23 GB), in the bucket runs of section 8.1. In 32 of 61 cycles the resume had to read a large snapshot from a far or slow bucket. In each of the 32, the first request returned 504 after 10.05 s with an empty body. The client sent the request again each second and sent no other command. Each of the 32 then returned 200 with the right count. In the slowest cycle the first 200 came after 213 s and 19 requests. Raw runs
The limit is on the decision of the router, and not on the whole request. In an earlier run, three requests returned 200 after 10.09 to 10.87 s.
A 504 after 10 s has a second cause, and the state of the actor separates the two. During a slow resume the actor reads ACTOR_STATE_RESUMING. After a database outage, one actor that read ACTOR_STATE_RUNNING returned the same 504 on every request. Section 8.2 has that case.
Our position
Parking hides a short shortage. It does not add capacity. A pool that parks requests all day is too small, and your users pay for that with seconds on every request.
Three things follow for your client and your alerts:
- Client timeout. Allow at least 10 seconds for a request to an actor that can be suspended. The budget is 5 seconds, a resume that started late still completes, and the Envoy dataplane ends the wait 5 seconds after the budget.
- Retry a 504 too. A 504 after 10 seconds on a request to a suspended actor means that the resume is still at work. Send the request again after a pause, and send no
revert. A revert of an actor inACTOR_STATE_RESUMINGis refused, as section 8.2 shows. - Retry. Retry a 503 with the text
no worker has roomafter a pause. Do not retry it in a tight loop, because each retry takes a place among the parked requests. - Alert. The router counts parked requests in the metric
atenet.router.parking.activeand requests over the limit inatenet.router.parking.rejected. Alert when the first one stays above zero.
All of this section is the Envoy dataplane, where the router process of Substrate handles each request. We did not see a 503 from a full pool on the agentgateway dataplane. The kagent run uses agentgateway, and it did fill its one worker: 16 actors ran, and 8 of 24 parallel turns waited and then completed. Sizing a pool has that run.
We did not test more than 1,024 parked requests. Our bursts into a full pool stopped at 32 requests, and each request had its own kubectl ate proxy. Later bursts of up to 300 went through the router ingress to free workers. No request got the 503 of a full pool. Sizing a pool has those.
The project site states 500+/s suspend and resume activations. One node of ours finished 32 to 39 resumes each second with up to 300 at one time. It served 600 of 600 at 300. Sizing a pool has the runs.
Sizing a pool
The limits that you declare decide how many actors run at one time, and disk decides how many suspended actors you can keep.
Someone asks how many agents your cluster can hold. The question has two answers, and they differ by orders of magnitude. One answer is the number of actors that run at the same moment. The other is the number of actors that exist and wait in a suspended state.
When you finish this section, you can compute both numbers for a pool and choose the limits of a worker. You can also name the resource that runs out first.
Running actors: the arithmetic of declared limits
A worker reports a capacity, and each running actor books a part of it. WorkerPool showed where the capacity comes from. The booking is the limit that the ActorTemplate declares, plus a memory overhead that the sandbox runtime reports. The scheduler never looks at what an actor uses.
The number of actors that one worker runs is the smallest of three values:
- the CPU limit of the worker, divided by the CPU limit of the actor
- the memory limit of the worker less 64 MiB, divided by the memory booking of the actor
- 1,000, the default of
--max-actors
We measured the left column on the x86 server guest (24 vCPU, 47 GB, nested KVM). The pool had two workers with a limit of 2 CPUs and 3 GiB. Each actor declared 1 CPU and 256 MiB. Four actors ran, two on each worker. Eight requests that needed a fifth running actor all returned 503. CPU was the limit: each worker had booked 512Mi of 3008Mi. Raw runs
We measured the right column on a second x86 server guest (16 vCPU, 31 GB), in the kagent run. The kagent pool sets no limits, and each actor declares 1 CPU and 1 GiB. We started 24 turns at the same time, one run. The worker reached 16 running actors and 16000 of 16000 millicores, and the other 8 turns waited. All 24 turns completed, in 4.3 to 7.8 seconds. With 8 turns at the same time, all 8 completed in 2.5 to 3.6 seconds. Raw runs
The parallel turns chart in section 7.1 shows the time of each turn in both runs.
The second result looks like free capacity, and it is the opposite. That worker promised every CPU of the node to actors, and the control plane runs on the same node. Set limits, as WorkerPool explains.
The CPU value of a template is a booking for placement, and the worker also writes it into the sandbox as a CPU quota. That quota is a hard cap.
We measured a fixed amount of work under four limits on a second x86 server guest (16 vCPU, 23 GB). Each of eight rows had three runs. The actor used 0.25, 0.50, and 1.01 host cores at 250, 500, and 1000 millicores, with one thread or with four. At 2000 millicores, one thread used 1.12 cores and four threads used 1.96 to 2.01. The work took 27.1 to 27.6 seconds at 250 millicores and 4.9 to 5.4 seconds at 1000. Raw runs
WorkerPool has the table and the chart.
So a low value packs more actors on a worker, and each of them is that much slower when it is busy. Size the value from the CPU that one turn needs, not from the average. The actor still sees 2 CPUs at every limit, so set thread counts in the template yourself. We did not test several busy actors on one worker at the same time.
A burst of resumes inside the capacity of the pool is cheap.
We measured 16 resumes at the same instant on the x86 server guest (24 vCPU, 47 GB, nested KVM), 80 requests in five rounds. The median was 0.37 seconds, against 0.23 seconds for one resume alone, and all 80 returned 200. Request parking has the chart. Raw runs
A dead actor counts as a running one. An actor whose process has exited stays ACTOR_STATE_RUNNING, and it keeps its booking. Two such actors each held 1,000 millicores of a worker for the 20 minutes of our watch. A burst of 32 resumes then found 14 free places in a pool of 16. CRASHED and RevertActor shows how to find and free them.
Two reports by other people help you choose the CPU limit of an actor. We did not repeat either.
A contributor measured real CPU use on workers that were fully booked, in issue 3441. Seven actors with 1 CPU each used between 0.55 and 0.8 cores together, at the median. A generous limit therefore costs you most of a worker, because Substrate at v1.0.0 has no overcommit.
A user measured resume time against the CPU limit of the actor on a release candidate of v1.0.0, in issue 3431. The median was 0.46 seconds at 50 millicores, 0.23 seconds at 100, and about 0.15 seconds from 250 up. A limit below 250 millicores saves capacity and costs resume time.
Suspended actors: disk, in three places
A suspended actor books nothing on a worker. You can keep far more suspended actors than running ones. Each of them costs disk.
The footprint chart in section 3.4 shows these sizes as bars.
We measured disk use on the x86 server guest (24 vCPU, 47 GB, nested KVM) with 25 suspended actors, one sample. The actors held 8,750 MiB of memory in total. The node held 8,801 MiB of snapshots and the object store held 10,824 MiB. After we deleted the actors, the node held 1 MiB of snapshots and the object store held 2,064 MiB. A cache of 1,765 MiB stayed on the node in both samples. Raw runs
Three rules come out of those numbers:
- A memory snapshot is as large as the memory that holds data. An actor with 200 MiB of random bytes made a snapshot of 201.9 MiB. Random bytes are the worst case. The same actor with 200 MiB of zero bytes made a snapshot of 4.0 MiB, and with half of each, 103.8 MiB. On the microVM class the snapshot also holds the guest, and it was 59 to 70 MiB larger, as sandbox classes shows.
- You pay two times for 30 minutes. With the default timers, the node keeps its copy for 30 minutes after the suspend. The object store has a second copy after 5 minutes. Snapshot fidelity, and where a snapshot lives has the timeline.
- Golden snapshots stay. The 2,064 MiB that remained belong to the five templates. Deleting actors does not free that space.
The disk that one node needs for snapshots is therefore the number of suspends in 30 minutes, times the snapshot size. A node that suspends 200 actors of 200 MiB each half hour needs 40 GiB for snapshots alone. The snapshot directory is a path on the node, and Kubernetes charges it to no pod.
On a pool that spans nodes, size each node by itself. A suspended actor resumes only on the node of its last suspend, for 30 minutes with the default timers. In our runs on two worker nodes, no actor moved off a node that stopped. When the node returned, Kubernetes left the new worker pods on the other node. Placement has those runs.
Many resumes at one time
A pool with free workers still has a limit on how fast it starts actors. We sent bursts of resumes to one node and timed each request. Figure 4.5b shows every request of two runs.
We measured the first run on the x86 server guest (24 vCPU, 47 GB, nested KVM). It had one kind node and a pool of 200 gVisor workers. The actor was a Python counter with a 128 MiB limit, and each actor had its own snapshot on the node. One client process in a pod sent each burst to the router, over one or two HTTP/2 connections. Each burst size ran three times, and all 933 requests returned 200. Raw runs
| Resumes at one time | Median of each burst | Slowest request | Resumes each second |
|---|---|---|---|
| 1, 30 times in a row | 0.36 s (0.33 to 0.40) | 0.40 s | not a burst |
| 10 | 0.38 to 0.40 s | 0.43 s | 23 to 24 |
| 50 | 1.16 to 1.34 s | 1.38 s | 36 to 38 |
| 100 | 2.21 to 2.26 s | 2.73 s | 36 to 39 |
| 150 | 3.16 to 3.42 s | 4.14 s | 36 to 38 |
The rate stops rising at about 37 resumes each second. From there a larger burst only waits longer, and twice the burst took twice the time. A first start from the golden snapshot ran at the same rate: 34 to 38 each second for 150 new actors, in three waves.
We measured a second run on the same guest, on a new kind node with a limit of 700 pods. The pool grew from 50 to 300 workers in steps of 50, and each step was ready in 17 to 21 s. At every step the Kubernetes API answered /readyz in a median of 105 to 108 ms, so 300 workers did not slow it. The actor was the same counter, and each request opened its own connection. A single resume took a median of 0.35 s, in 20 resumes. Raw runs
| Resumes at one time | Bursts | Median of each burst | 95th percentile | Resumes each second |
|---|---|---|---|---|
| 150 | 3 | 3.15 to 3.55 s | 3.83 to 4.09 s | 35 to 39 |
| 200 | 2 | 4.33 and 4.55 s | 5.42 and 5.61 s | 35 and 36 |
| 300 | 2 | 7.29 and 7.42 s | 9.10 and 9.17 s | 32 and 32 |
1,448 of 1,450 requests in these bursts returned 200. The other two got no answer in 90 s, and the rate counts the answered requests up to the slowest answer. A first start of 300 new actors, 150 at a time, ran at 30 and 34 each second. So the rate held at 32 to 39 from 50 to 300 at one time, and fell a little at 300.
What sets that rate
Figure 4.5c shows the restore inside the worker, the step of a resume that took the most time.
We measured this in the second run, from the restore log of atelet and the CPU of each process, sampled each second. Alone, the restore inside the worker took a median of 0.27 s, in 20 restores. In eight bursts, a median of 75 to 116 restores were in progress at one moment. Each one took a median of 2.1 to 2.9 s inside the worker, and each burst finished 35 to 41 restores each second. So nothing limits how many restores start at once, and each one slows when about 100 run together. Raw runs
We ruled out the parts that we could change or watch, in 13 bursts with no hung request:
ate-api-server: with 1, 2, and 4 replicas the rate was 36 to 37, 35 to 39, and 32 to 36 each second. It used 0.24 to 0.39 cores on average in a burst, and no thread more than 0.06 cores.- The router: with 1 replica of
atenet-routerthe rate was 35 to 39, and with 4 replicas 35.5 and 36.3. - Postgres,
kube-apiserver, and etcd: each used less than 0.25 cores on average, and no thread more than 0.07 cores. - The client: one or four curl processes gave the same rate. So did one connection for each request, against one or two in the first run.
- The node: 9.5 to 11.4 of its 24 cores were busy on average in a burst, with peaks of 14 to 16. I/O wait was 0.02 to 0.12 cores on average, in the bursts that recorded it.
The busiest processes were the gVisor sandboxes, at 2.1 to 2.9 cores on average, and the worker processes, at 1.5 to 2.2. So the limit is on the node, inside the gVisor restores that run together. Plain CPU, disk wait, and the control plane of Substrate are ruled out. We did not measure its cause. Lock contention in the guest kernel or in gVisor, and a cost of nested virtualisation, are possible. We are sure of what the limit is not, and less sure of where it is.
More replicas of ate-api-server
We measured two bursts of 150 with four replicas of ate-api-server, in the second run. 33 of 300 requests returned 500 and 3 returned 503. The bundled Postgres allows 100 connections, and the four replicas ran it out of them. The API server logged FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute (SQLSTATE 53300) and FATAL: sorry, too many clients already. Its arguments have no flag for the size of its connection pool. Raw runs
Eight actors ended ACTOR_STATE_CRASHED, and one revert brought each one back, in 0.33 to 0.38 s. Two more actors stayed in ACTOR_STATE_RESUMING for at least 4 minutes, and section 8.2 has them.
Our position: keep the two replicas of ate-api-server that the install sets, when you use the bundled Postgres. More replicas did not raise the rate on one node, and four of them failed resumes and crashed actors. If you need more replicas for availability, raise the connection limit of Postgres first. We did not test that change.
The count of stored actors
The count of stored actors did not change these numbers. In the first run we raised the count to 10,750 actor rows in Postgres. A single resume took a median of 0.36 to 0.38 s at each of four sizes, with 10 resumes each. The database was 20 MB at 10,750 rows, and kubectl ate get actors took 0.86 s. The creates ran at 76 to 86 each second, and none of 10,000 failed.
Our position: plan the pool for the burst, and not only for the peak count. If 100 sessions wake in the same second on one node like ours, the last one waits about 2.7 s. With 300 it waits about 9 s. Spread a wake-up of many actors over time in your client. More nodes are the other answer, and we did not test them, so measure that rate yourself.
Two limits stopped the runs. Each suspended actor of this kind kept 12.9 MB on the node disk, and 450 of them left 3.9 GB free on the guest. We also stopped at 10,750 rows, below the 20,000 that we planned. In the second run the delayed upload of 300 snapshots took the guest disk from 4.9 to 4.0 GB in one minute. So 200 and 300 at one time ran twice each, and not three times. An idle worker used a median of 16.7 MB of memory, in 200 workers.
No autoscaler
Substrate v1.0.0 has no code that changes spec.replicas. The WorkerPool has a scale subresource, so a HorizontalPodAutoscaler or your own controller can change the count. The controller publishes ate.workerpool.desired_workers and ate.workerpool.ready_workers for that purpose. We did not test an autoscaler.
Plan as if you have none. Request parking holds a request for 5 seconds. Do not plan for a new worker pod to start and register in that time.
We measured two such starts, each once. On the x86 server guest (24 vCPU, 47 GB, nested KVM), a new pool of 200 workers was ready after 75 s. On the second x86 server guest (16 vCPU, 23 GB), we scaled a pool from 4 workers to 6 while a request waited for room. That request returned 200 in 1.55 s, 1.7 s after the scale command. Both clusters had the worker image on the node already, so a first pull is not in these numbers. Raw runs
Size the pool for the peak of running actors, with room to spare.
What others reported at scale
Our largest cluster had two worker nodes. Our largest count was 10,750 actor rows on one node, of which 150 had a snapshot of their own. The project site states 10× the density of container runtimes. The documentation gives a benchmark method and no result. We measured two parts of that claim and made no comparison with a container runtime. A suspended actor used no worker and 12.9 MB of node disk, and an idle worker used 16.7 MB of memory.
These reports come from the issue tracker of Substrate. Their authors ran them, and we did not.
| Report | Conditions | Result |
|---|---|---|
| Issue 3479, pools without limits | 320 workers, eight on each 8 vCPU node, no limits on the pool | every worker registered the whole node, so placement never refused an actor |
| Issue 3408, density on one node | one node with 192 vCPUs and 503 GiB, gVisor, a development build | at 6,200 actors, 1,187 sandboxes died and all actors stayed RUNNING. With a newer gVisor build, 5,600 actors ran with no deaths, and at 6,000 the node ran out of memory |
| Issue 3702, Postgres under load | 9,000 actors in create and delete cycles, 900 workers, v1.0.0-rc.1 | Postgres used 53 to 60 of 64 cores. The first command came 4 to 18 seconds after create. With 2,700 workers the wait went away |
| Issue 3101, disk after asynchronous suspends | a 50 GB node disk, 10 suspends of 512 MB each | the kubelet evicted both worker pods and then atelet, and the node stayed under disk pressure for more than 34 minutes |
Read the table as four separate limits: missing limits, node memory, Postgres row locks, and node disk. Each one appeared before CPU ran out. The Postgres report adds one design hint. Many small workers behaved better than few large ones at that scale: about 3 actors on each worker, not 10.
A procedure
- Measure the peak memory of one actor, and set the template limit a little above it.
- Set the CPU limit of the actor to 250 millicores or more.
- Choose the actors for each worker. Set the worker limits to that count times the booking of one actor, plus 64 MiB. On
microvmthe memory booking is the limit plus an overhead, 7.6 MiB in our run. - Set
replicasto the peak of running actors divided by that count, plus spare workers. - Size the node disk for 30 minutes of snapshots, and watch it.
Identity and Access
Every caller and every actor has an identity, and the default install lets all clients share one.
Client authentication
Substrate checks a credential at two places, the API and the router, and the default install gives every kubectl ate user one shared owner identity.
A colleague gets access to your test cluster and runs kubectl ate get actors. The command prints a warning, then lists every actor of every team. The colleague can also suspend, revert and delete them. Nothing is broken. The install works this way until you change it.
When you finish this section, you can name the credential that each kind of client needs. You can also say what the default install lets every user do.
This section mixes two kinds of evidence. A sentence marked as seen in our runs comes from a recorded probe, and so does every listing. The other statements come from the code and the documentation at v1.0.0.
Two places check a client
| Place | Who calls it | Credential | What decides access |
|---|---|---|---|
ate-api-server, the API | kubectl ate, a harness, your controller | a client certificate, or a bearer JWT | access policies, in section 5.2 |
atenet-router, the path to an actor | whatever sends requests to actors | a client certificate | one list of allowed identities for the whole install |
The two places share no rule. A principal can own an atespace in the API and still be unable to call an actor. The router serves it only if its certificate is on the list of the router.
The API: a certificate or a token
Principal: the identity that ate-api-server derives from a credential, and the name that access policies grant roles to.
ate-api-server accepts two credentials, in this order:
- A client certificate from the pod identity signer of the cluster. The principal is the SPIFFE ID in the certificate, such as
spiffe://cluster.local/ns/kagent/sa/kagent-controller. - A bearer JWT from an issuer that the server trusts. The principal is the
subclaim. For a Kubernetes ServiceAccount token that issystem:serviceaccount:<namespace>:<name>.
A call with neither gets the gRPC error Unauthenticated. The install trusts one issuer, the Kubernetes API server of the cluster, for tokens with the audience api.ate-system.svc. The list of issuers is a ConfigMap that the server reads only at startup.
What the default install does
Every kubectl ate command in our runs began with the same line.
Warning: no --token-file given; acting as the shared identity system:serviceaccount:ate-system:ate-client rather than as yourself. Pass --token-file to act as yourself.Without --token-file, the CLI asks Kubernetes for a token of the ServiceAccount ate-client and sends that. Every person who uses the CLI this way is the same principal. The installer told us what that principal can do.
Warning: system:serviceaccount:ate-system:ate-client is a bootstrap owner, so anyone who can mint a token for it is a global owner of this install. It is the default so the demos, benchmarks and e2e tests work; for a shared or production install, set --ateapi-authz-bootstrap-owners (ATE_API_AUTHZ_BOOTSTRAP_OWNERS) to your operators' own principals. See docs/authorization.md.So the true access rule of a default install is a Kubernetes rule. Whoever can create a token for ate-client, or create a pod in ate-system, owns every actor.
To act as yourself, create a ServiceAccount for each person and pass its token. We ran these commands for a ServiceAccount named alice, against the atespace lab-v.
kubectl create namespace ate-users
kubectl -n ate-users create serviceaccount alice
kubectl create token alice -n ate-users --audience api.ate-system.svc --duration 1h > alice.token
kubectl ate --token-file alice.token get actors -a lab-vThe file holds a bearer token. Run umask 077 before you create it, so that no other user of the machine can read it.
The principal is then system:serviceaccount:ate-users:alice. It can do nothing until an access policy grants it a role. The server accepted the token and refused the call.
…Error: failed to list actors: rpc error: code = PermissionDenied desc = permission denied: principal "user:system%3Aserviceaccount%3Aate-users%3Aalice" lacks "can_list_actors" on "atespace:lab-v" PermissionDenied means that the server knows who you are. A credential that it cannot verify gets a different error, and the error does not say why.
What --token-file held | Result of get actors |
|---|---|
a token for alice with the audience api.ate-system.svc, and no role | PermissionDenied, with the principal and the missing permission |
a token for alice with the default audience of Kubernetes | Unauthenticated desc = invalid bearer token |
| a text that is not a token | Unauthenticated desc = invalid bearer token |
We ran each row once on the x86 server guest. The second row is the mistake to expect: kubectl create token without --audience gives a token that Substrate rejects. Access policies continues with the same ServiceAccount.
Three facts from the documentation limit how far you can trust this setup:
- A token outlives its ServiceAccount. The server checks the signature and the expiry, and it does not ask Kubernetes if the account still exists. The token works until its expiry time plus five minutes, which the server allows for clock differences. To stop a person at once, remove the person from the access policies. A bootstrap owner is in no policy: remove it from the server flag and restart
ate-api-server. - The CLI needs port-forward rights. Without
--endpoint,kubectl ateopens a port-forward intoate-system. In practice that right covers every pod and port in the namespace, so it also reaches the admin ports of the dataplane. - Issuers are not part of the principal. If you trust two issuers, either one can issue a token with the
subof a principal from the other.
A network rule stands before all of this. In our kagent run, a pod in the namespace default could not open a connection to the API at all. The same probe from the namespace kagent reached the server. The install admits only namespaces with the label ate.dev/api-access=true.
The router: a certificate on one list
A request to an actor does not go to the API. It goes to atenet-router, and the router demands a client certificate whose SPIFFE ID is on its list. The list is the router flag --ingress-allowed-spiffe-ids. In our ate-setup installs it held one identity, spiffe://cluster.local/ns/ate-system/sa/atenet-client. A kagent install is different. The kagent installer sets the list to the SPIFFE ID of the kagent controller, and atenet-client is not on it.
We sent the same request from three clients, on the Envoy dataplane and then on the agentgateway dataplane. The target was the actor my-counter-1 in the atespace ate-demo-counter. The figure sets the two checks side by side, with the results of the Envoy dataplane.
* TLSv1.3 (IN), TLS alert, certificate unknown (558):
curl: (56) OpenSSL SSL_read: OpenSSL/3.3.2: error:0A000416:SSL routines::ssl/tls alert certificate unknown, errno 0curl: (56) OpenSSL SSL_read: OpenSSL/3.3.2: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0cert right-id: subject=CN = HBCORTIJRCAXAVJEJY3YTOYS3X X509v3 Subject Alternative Name: URI:spiffe://cluster.local/ns/ate-system/sa/atenet-client
### 23-rightid-https-443
preserved memory count: 7 | preserved file counter: 7 | preserved rootfs counter: 7
code=200 t=0.035022Three results from these probes matter to you:
- Both dataplanes authenticate. The router Service of the agentgateway dataplane lists the plain ports 80 and 8081. Nothing listened on them, and both TLS ports demanded a certificate. A later probe of every port gave the same result for 443 and 8444 on both dataplanes. It found one port that needs no credentials: the stats port 15020 of the agentgateway router, which answers
/metricsto any pod. The router and ingress has the port table. - The allowed ServiceAccount does not exist. After the install,
ate-systemhad no ServiceAccount namedatenet-client. We created it before the third probe.kubectl ate proxygets a certificate for that identity by another path, which the router and ingress describes. - The list has no atespaces. The one allowed identity reached an actor in an atespace that it has no role in. A user reported the same as issue 3589.
The last result decides your design. The router answers one question: is this client on the list. It does not ask which actor the client is allowed to call. Put your own service between your users and the router, give only that service an allowed identity, and check each user there.
We did not test a second token issuer, a token of a deleted ServiceAccount, or a list with more than one identity.
Access policies per atespace
An access policy grants one of three roles on one atespace, and the atespace is the smallest unit that Substrate can protect.
Two teams share a cluster. Team A must not suspend, read, or copy the actors of team B. You look for a way to grant access to one actor or one template, and the API has none. The unit of access is the atespace, and that fact shapes how you divide your work.
When you finish this section, you can write an access policy, say what each role allows, and name the things that a policy cannot separate.
Most of our runs used the default owner. For this section we used one ServiceAccount, alice, and the atespace lab-v. We gave it no role, then the viewer role, then the editor role, and we recorded what the API allowed. The denial texts and the policy listings below come from that run. The rules that we did not run are marked as code or documentation.
How a call is checked
ate-api-server checks every call in two steps. Authentication turns the credential into a principal, as client authentication describes. Authorization then allows the call in one of three cases:
- the principal is a bootstrap owner
- an access policy gives the principal a role that permits the call
- the caller is a Substrate component with the system role that the call needs
In every other case the call fails with PermissionDenied. Authorization cannot be turned off. The model runs inside ate-api-server on an embedded OpenFGA engine, and it stores its data in the same Postgres database as the actors.
Access policy: the list of role bindings for one scope. Each scope has at most one policy, and its name is default.
Two scopes, five roles
A policy covers the global scope or one atespace. The global scope has two roles and an atespace has three. Roles inherit: an owner is also an editor, and an editor is also a viewer.
| Role | Can do | Cannot do |
|---|---|---|
| global owner | all of the rows below, create atespaces, manage workers | mint an actor JWT |
| global viewer | read every atespace, list workers | change anything |
| atespace owner | all that an editor can, write the policy of the atespace, delete the atespace | touch another atespace |
| atespace editor | create, resume, suspend, revert and delete actors, write templates, egress policies and tags | change the policy |
| atespace viewer | read actors, templates, tags, egress policies and the policy | change anything |
Workers have no atespace. Only a global role can list them, so a team with an atespace role cannot run kubectl ate get workers.
Bootstrap owners
Bootstrap owner: a principal from the server flag --authz-bootstrap-owners that always holds the global owner role, whatever the policies say.
The server reads the list at startup and does not start with an empty list. With ate-setup, the default is the shared ServiceAccount ate-client, and the installer prints a warning about it. The kagent distribution of Substrate adds its own controller to the list. We read this argument from the ate-api-server Deployment of our kagent cluster:
- --authz-bootstrap-owners=spiffe://cluster.local/ns/kagent/sa/kagent-controller,system:serviceaccount:ate-system:ate-clientTwo principals own that install. The first is the pod certificate of the kagent controller. The second is still ate-client, so the warning from client authentication applies to a kagent install as well.
For a shared cluster, set the list to the principals of your operators and keep it short. Grant daily administrators the global owner role in the global policy. You can remove a role in a policy through the API. You can remove a bootstrap owner only with a restart of the server.
Write a policy
A policy is a short manifest. This is the file that gave the ServiceAccount alice the viewer role on lab-v.
metadata:
name: default
bindings:
- role: viewer
members:
- user:system:serviceaccount:ate-users:aliceA member is the principal with the prefix user:. Three commands create, read, and change a policy. They follow the pattern of the other kubectl ate objects:
kubectl ate create access-policy -a lab-v -f policy-viewer.yaml
kubectl ate get access-policy -a lab-v -o yaml > current.yaml
kubectl ate update access-policy -a lab-v -f current.yamlThe fourth command removes every binding of the scope. Run it only when you mean that:
kubectl ate delete access-policy -a lab-vFor the editor role we changed one word in the output of get -o yaml and sent that file with update. This is the stored policy after the update.
bindings:
- members:
- user:system:serviceaccount:ate-users:alice
role: editor
metadata:
createTime: "2026-11-12T12:51:47.968679335Z"
name: default
uid: a06fc82d-e269-4ce3-8471-00c1b537adff
updateTime: "2026-11-12T12:51:51.605795572Z"
version: "2"Use --global in place of -a for the global policy. Four rules save you a failed command:
- A global policy accepts
ownerandviewer. An atespace policy also acceptseditor. updatereplaces the whole policy. It needs themetadata.uidandmetadata.versionthatget -o yamlprints, and it fails if the policy changed after yourget.- You cannot delete the global policy. Update it with no bindings.
- A change applies to the next call.
Egress policies follow the same update rule. Our first update egress-policy failed until the manifest carried both fields.
What each role was refused
We ran the same ServiceAccount through four stages. Each denial has the gRPC code PermissionDenied and the same sentence, with the missing permission and the scope in it.
…Error: failed to create actor: rpc error: code = PermissionDenied desc = permission denied: principal "user:system%3Aserviceaccount%3Aate-users%3Aalice" lacks "can_create_actor" on "atespace:lab-v" Role of alice on lab-v | Call | Result |
|---|---|---|
| none | list actors in lab-v | denied: lacks "can_list_actors" on "atespace:lab-v" |
| none | list atespaces | denied: lacks "can_list_atespaces" on "global:root" |
| viewer | list actors and templates in lab-v | allowed |
| viewer | create an actor in lab-v | denied: lacks "can_create_actor" on "atespace:lab-v" |
| viewer | list actors in the atespace lab-c | denied: lacks "can_list_actors" on "atespace:lab-c" |
| viewer | list workers | denied: lacks "can_list_workers" on "global:root" |
| editor | create, suspend, and delete an actor in lab-v | allowed |
| editor | create an actor in lab-c | denied: lacks "can_create_actor" on "atespace:lab-c" |
| editor | create an access policy on lab-c | denied: lacks "can_create_access_policy" on "atespace:lab-c" |
| none, after the owner deleted the policy | list actors in lab-v | denied: lacks "can_list_actors" on "atespace:lab-v" |
We measured the delay of a role change on the x86 server guest (24 vCPU, 47 GB, nested KVM), once for each change. The first call 2 seconds after the create was allowed, and so was the first call 2 seconds after the update. The call 0.2 seconds after the delete was denied. So a change took effect within 2 seconds, and the delete within 0.2 seconds. Raw runs
Two details of the denial help you. The error names the principal with each colon written as %3A, so decode it before you paste it into a policy. And the scope global:root in a denial means that no atespace role can allow the call.
After the install, the global scope had no policy at all: kubectl ate get access-policy --global answered global has no access policy. The shared ate-client identity still owned everything, because it is a bootstrap owner.
What a policy cannot separate
The atespace is the only boundary. Five limits follow, and you must design around each of them.
- No grant for one actor or one template. An editor of an atespace can change every actor in it.
- No groups. A member is one principal. A team of ten needs ten members.
- An editor can copy an actor. A tag is a copy of the memory and the disk of an actor, with every secret in it. Any editor can create a tag and start a new actor from it.
- Names carry the grants. If you delete an atespace and create one with the same name, a late request for the old one can apply to the new one. Never use an atespace name two times. Atespaces and names explains the naming rules.
- The router does not read policies. A client on the list of the router reaches actors in every atespace.
Our kagent run adds a practical case. kagent put every session of every agent into one atespace named kagent. In that install, Substrate policies cannot keep two kagent users apart. That job belongs to kagent.
System roles
The components of Substrate hold roles that no policy can grant. Each role is bound to the pod certificate of one component by a flag of ate-api-server.
| Role | Held by | Allows |
|---|---|---|
ingress_router | atenet-router | resume any actor, list actors in all atespaces |
egress_gateway | atenet-egress, and any other identity that you name with the repeatable flag --egress-gateway-spiffe-id | read actors and egress policies, mint actor JWTs |
atelet | atelet on every node | read the egress policy of any actor |
controller | ate-controller | create, get, list, update, delete and drain workers |
api_server_sidecar | the ate-api-server pod, which its sidecars share | read actors, so that the object store plugin can limit a storage credential to one actor |
Two rows deserve a second look. The router can resume every actor, which is why the list of the router in client authentication matters so much. And only the egress gateway can mint an actor JWT. A global owner cannot, because the token lets its holder act as the actor. Actor JWTs follows that token.
Our position
Use one atespace for each group of people that must not read the others' actors, and accept that this is the only line you get. Replace the default bootstrap owner before a second person uses the cluster. Do not count on Substrate to separate the end users of your product. Your own service has to do that, in front of both the API and the router.
Actor JWTs
An actor does not store its own identity token: the egress gateway mints an actor JWT and puts it in a header that an egress rule names.
Your actor calls a service that you run, and the service must know which actor is calling. The usual answer is a secret inside the workload. An actor is a bad place for a secret, because its memory goes into every snapshot and any editor can copy it. Substrate gives the actor an identity that the actor does not store. The actor can still read the token when a service returns it.
When you finish this section, you can write the egress rule that attaches an actor JWT and list the claims in the token. You can also say what a service must check before it trusts one.
Our runs show two such rules at work. One is the rule that kagent writes for each session. The other is our own rule, which sent a token to a service that returns its request headers, so we could decode the token. The limits at the end come from the documentation at v1.0.0, and we say which of them we saw.
The gateway asks, the actor does not
Actor JWT: a signed token, minted by ate-api-server, that names one actor and one or more audiences.
No API lets an actor request its own token. The token appears only on outbound requests, and only where an egress rule asks for it.
The steps are these:
- The actor sends a plain request to a host that its egress policy allows.
- The egress gateway finds the rule for that host and sees the effect
actorJwt. - The gateway calls
MintActorJwtonate-api-server, with the audiences and the lifetime from the rule. - The gateway writes the token into the header that the rule names, and sends the request on.
Only the egress gateway is allowed to call MintActorJwt. A global owner is refused, as access policies per atespace explains.
The rule that kagent writes
kagent needs this mechanism for its own traffic. Each session is an actor, and the harness in the actor reports to the kagent controller. This is the first rule of the egress policy that kagent wrote for one session in our run.
rules:
- http:
effects:
replaceHeaders:
- actorJwt:
audiences:
- kagent-runtime
expirationSeconds: "300"
header: authorization
prefix: 'Bearer '
hostnames:
- kagent-controller.kagent.svc.cluster.local
ports:
numbers:
- 8083Read it from the bottom up. The rule matches plain HTTP to one host on port 8083. For a matching request, the gateway replaces the authorization header with Bearer and a fresh token. The token is for the audience kagent-runtime, and it lives 300 seconds, the shortest lifetime that the API accepts.
The log of the egress gateway shows the result. The harness called the controller, the request carried an authorization header, and the controller answered 200.
2026-11-12T10:19:50.777573Z info request request.id=64 gateway=default/default listener=listener1 route=default/route0 endpoint=kagent-controller.kagent.svc.cluster.local:8083 src.addr=10.244.0.23:59184 http.method=POST http.host=kagent-controller.kagent.svc.cluster.local http.path=/kagent.runtime.v1.TaskStoreService/SettleTask http.version=HTTP/2.0 http.status=200 grpc.status=0 protocol=http ate.actor.name=session-01a1cf43-d760-747a-b907-5e0640126e4e ate.actor.uid=d64e21f4-05eb-49a3-a81e-69ca2d893b06 ate.atespace=kagent …This rule is on the http kind, so the token crossed the cluster network without TLS between the gateway and the controller. Use the https kind for any service outside your cluster. Credential injection covers the other effect of replaceHeaders, which reads a secret.
What the token says
We wrote a rule like the one of kagent for an actor named jw2 in the atespace lab-v. The audience was lab-echo, the lifetime was 600 seconds, and the host was a service in the cluster that returns its request headers. The actor called that service, and we decoded the token that came back. The listing shows the header and the claims, and it leaves out the signature.
…
header: {
"typ": "JWT",
"alg": "ES256",
"kid": "E38XLIprTZzZkY7RinXF7sPyw-HxHT-ysnVHFvvTCAU"
}
claims: {
"iss": "https://idp.ate-system.svc",
"sub": "actor/lab-v/jw2",
"aud": [
"lab-echo"
],
"exp": 1794488570,
"nbf": 1794487670,
"iat": 1794487970,
"jti": "VLZDMDBJ2UA3ME2Z5M62BADKQK",
"actor.ate.dev": {
"atespace": "lab-v",
"actorName": "jw2",
"actorUID": "93e670a4-3215-41e5-be33-4dffaca75d42"
}
}
…| Claim | Value | In our token |
|---|---|---|
iss | the issuer URL of the install | https://idp.ate-system.svc |
sub | actor/<atespace>/<name> | actor/lab-v/jw2 |
aud | the audiences from the rule, always an array | ["lab-echo"] |
exp | the issue time plus expirationSeconds, which must be 300 to 3600 | iat plus 600 seconds |
nbf | five minutes before iat | iat less 300 seconds |
iat | the issue time | present |
jti | a random id | present |
actor.ate.dev | an object with atespace, actorName and actorUID | all three fields |
The header named the algorithm ES256. Its kid was equal to the one key in the key set that the issuer published. The code also allows RS256.
We saw four more things in that run, once each, on the x86 server guest:
- The gateway uses a token again. Two requests in a row carried the same token, with the same
jti. A request after a suspend and a resume of the actor carried it too. Sojtiis not a request id, and a suspend does not end a token. - The actor must send the header. The effect replaces a header and does not add one. A request that the actor sent without an
authorizationheader reached the service with none. Our other requests sentBearer placeholder, and the gateway replaced it. - The actor can get the token. The service returned its request headers, so the actor read its own token in the response. Send a token only to a service that does not return it.
- One claim was absent. The discovery document lists a claim named
worker-for-actor.ate.dev. Our token did not have it.
Two details change how you use the token:
subis a name. An actor that you delete and create again with the same name gets the samesub. Bind toactorUIDif the difference matters.- Substrate adds no audience. The rule decides
aud, and the person who writes the rule also decides which host receives the token.
Create the policy before the actor asks for the name. In our first try, the actor asked for the host before any policy existed. We created the policy, and 4.5 seconds later the name still did not resolve. In the second try the policy came first, and the name resolved at once. The DNS relay describes one more slow update like this one.
What the service must check
A service that receives the token is a relying party in OpenID Connect terms. ate-idp-server publishes the discovery document at /.well-known/openid-configuration under the issuer, and the keys at /openid/v1/jwks. Our kagent install set the issuer to https://idp.ate-system.svc.
The service must do five checks:
issequals the issuer that you configured.- The signature verifies with the key from the key set that has the
kidof the token. audcontains the audience that the service expects.expandnbffit the current time.substarts withactor/.
Do not skip the last check. The same keys sign a second kind of token, with a sub that starts with worker-for-actor/. Substrate components use that kind to move snapshots. A service that accepts it treats a worker as the actor.
The default issuer is a Service name, so only a verifier inside the cluster can reach it. For a verifier outside, you must publish the two paths on a host that you own and set the issuer to that host. The installer does not create the Ingress, the DNS record, or the certificate.
Limits
The documentation lists these limits. We saw the second one in our run, and we tested none of the others:
- No revocation. A token is valid until
exp, also after you delete the actor. Keep the lifetime at 300 seconds unless a service needs more. - It is a bearer token. A service that returns its request headers in the response gives the token to the actor. Our header service did that, and we saw the same effect with an injected credential in our egress tests.
- A new actor with an old name. After you create an actor again, the gateway can send a token with the
actorUIDof the old one. The window is up to two thirds of the token lifetime (issue 2208). - No exchange for cloud credentials. Cloud token services want the JWT in the request body, and the gateway replaces only headers (issue 1661).
- Key rotation is manual. Keys do not expire. You add, activate and remove keys with
kubectl ate admin.
Three things stay unverified after our run. We did not check the signature with the published key, as a service must. We did not rotate a key. And we did not set up federation with a second issuer or a cloud provider.
Our position
Use the actor JWT for calls from an actor to services that you control. It removes a secret from the actor, and the 300 second lifetime bounds the damage of a leak. Do not treat the audience as the whole access decision. Every atespace editor can write a rule that sends a token with any audience to any host. So give each service its own audience, and make each service refuse every other one. Each service must also decide which actors in sub it serves.
Network
An actor reaches nothing until a policy names the host, and a removed rule does not close an open connection.
The router and ingress
Every request to an actor passes through atenet-router, which demands a client certificate with an allowed SPIFFE ID and calls ResumeActor before it forwards one byte.
Your actor answers through kubectl ate proxy. Then a service in another namespace calls the router directly, and the call fails in the TLS handshake with no HTTP status to read. Nothing is wrong with the actor. The router refused the caller before it looked at the request.
When you finish this section, you can address an actor through the router and name the identity that the router accepts. You can also read the three ways that it refuses a caller on each dataplane.
One Service for every actor
An actor has no Service and no hostname. Every actor in the cluster is reached through one Service, atenet-router.ate-system.svc, and the caller names the target in a header.
Routing header: the HTTP header ate-target-actor, with the value <atespace>/<actor>, such as ate-demo-counter/my-counter-1. The Host header stays free for your application.
The Service has two ports that matter. Port 443 carries plain HTTPS requests to port 80 of the actor. Port 8444 accepts an HTTP CONNECT over TLS, for an actor that listens on another port. The routing header goes on the outer CONNECT, and the port goes in its authority. Our probe sent CONNECT actor:80 and got the same answer as on port 443.
| Envoy dataplane | agentgateway dataplane | |
|---|---|---|
| Containers in the router pod | atenet-router and envoy | one agentgateway |
| Listeners for callers | 8443 and 8444 | 8443 and 8444 |
| Service ports | 443 and 8444 | 443, 8444, 80, 8081, and 15020 |
| Plain HTTP on 80 or 8081 | no such Service port | connection refused |
The agentgateway Service lists ports 80 and 8081, and the NetworkPolicy admits them. No process listens on them. A plain HTTP request to either port failed in 3 ms with a refused connection. Do not read the Service as proof of an open port.
atenet-router ClusterIP 10.96.5.247 <none> 443/TCP,8444/TCP,15020/TCP,80/TCP,8081/TCP …A later probe tried every port from two pods. One was in another namespace and had no certificate. The other was in ate-system and held the allowed identity.
| Port | Envoy dataplane | agentgateway dataplane |
|---|---|---|
| Service 443, container 8443 | TLS. The connection is closed without a client certificate. With the allowed identity and the routing header: 200. Without the header: 404, invalid actor reference | TLS. Without a client certificate: the alert certificate required. With the allowed identity and the routing header: 200. Without the header: 404, ingress failed |
| Service 8444 | TLS. The connection is closed without a client certificate. A POST with the identity gets 404 | TLS, with the same alert. A POST with the identity gets 405, method not allowed, because the listener accepts CONNECT only |
| Service 80, container 8080 | not in the Service, timeout | in the Service, connection refused, no listener |
| Service 8081 | not in the Service, timeout | in the Service, connection refused, no listener |
| Service 15020, stats | not in the Service, timeout | open with no TLS and no credentials. /metrics returns 200 to a pod in another namespace |
| Pod port 15021 | timeout | 404 on plain HTTP, the readiness listener |
| Pod port 15000 | timeout | timeout |
We measured each port once on each dataplane, on a second x86 server guest (16 vCPU, 23 GB), with a client timeout of 4 s. Ports 443 and 8444 need a client certificate on both dataplanes. With agentgateway the Service has five ports and the container declares six, and the rendered configuration binds 8443 and 8444 for callers. Ports 80 and 8081 are dead entries in the Service. With Envoy the Service has two ports. Raw runs
stats 15020 /metrics from another namespace: 200|13394|58|# HELP agentgateway_substrate_request_parking_active Number of requests waiting for a Substrate actor to become routable.|
…
service 8444 with allowed client cert, POST with routing header: 405 exit=0 err=|method not allowed
service 443 with allowed client cert, no routing header: 404|ingress failedSecurity note: the stats port of agentgateway needs no credentials. Any pod that can reach the Service can read /metrics on port 15020, in plain HTTP. Our pod in another namespace got 13,394 bytes with 58 metric families, and the names include request parking for actors. We found no actor name and no header value in that output, and we did not read the content of every label. Until you have read your own output, treat the port as readable by every tenant of the cluster. A NetworkPolicy for port 15020 of the router pods closes it.
Use the short name of the Service. The router certificate is valid for atenet-router.ate-system.svc. The longer name atenet-router.ate-system.svc.cluster.local failed the certificate name check on both dataplanes.
One request, step by step
The router keeps no table of running actors. For each request it asks ate-api-server where the actor is, with the same ResumeActor call that wakes a suspended actor. The answer carries the state, the worker IP, and the worker identity. Then the router opens a mutual TLS tunnel to that worker and forwards the request.
The router log shows the call for an actor that was already ACTOR_STATE_RUNNING. Our prober sent one request per second, and each one produced this pair.
{"time":"2026-11-12T11:04:04.204261373Z","level":"INFO","msg":"ResumeActor","actor":{"type":"*ateapi.Actor","atespace":"lab-e","name":"e-run"},…
{"time":"2026-11-12T11:04:04.208776711Z","level":"INFO","msg":"ResumeActor result","actor":{"type":"*ateapi.Actor","atespace":"lab-e","name":"e-run"},"state":"ACTOR_STATE_RUNNING","workerIP":"10.244.0.36","workerSPIFFEID":"spiffe://cluster.local/ns/lab-e/sa/ateworker",…This design has a cost on the warm path. A running actor is only as available as ate-api-server and Postgres. We scaled ate-api-server to zero for 60 seconds. The running actor returned 503 with the body actor lab-e/e-run unavailable for 60 requests in a row. Section 8.2 has the full timeline.
We measured the allowed path on a second x86 server guest (16 vCPU, 23 GB, Envoy dataplane), one request each. A request on port 443 to a running actor took 0.037 s. The router log gives 9.2 ms of that to the ResumeActor call. The same request through CONNECT on port 8444 took 0.032 s. On the agentgateway dataplane the two requests took 0.035 s and 0.029 s. Raw runs
When no worker has room, the router holds the request and tries again. Section 4.4 covers that wait.
Who the router lets in
The router serves TLS only. It requires a client certificate from the pod identity signer of the cluster. Then it compares the SPIFFE ID in that certificate with a fixed list.
Ingress allowlist: the list of SPIFFE IDs that the router accepts, set at install with --ingress-allowed-spiffe-ids. The flag has no default. An identity on the list can reach every actor in every atespace.
That last sentence is the one to remember. The router authenticates the caller. It does not check that this caller is allowed to reach this actor. Put a service of your own in front of it when callers must see only their own actors. Section 5.1 covers the client side.
We sent the same request from three pods. The first had no client certificate. The second had a valid certificate for spiffe://cluster.local/ns/probe/sa/default, which is not on the list. The third had the allowed identity.
| Caller | Envoy dataplane | agentgateway dataplane |
|---|---|---|
| No client certificate | connection cut, Broken pipe in curl | TLS alert certificate required |
| Valid certificate, identity not on the list | TLS alert certificate unknown | connection reset |
| Allowed identity | 200 on 443 and through 8444 | 200 on 443 and through 8444 |
| Allowed identity, actor does not exist | 404, actor ate-demo-counter/does-not-exist not found | 404, ingress failed |
| Allowed identity, no routing header | 404, invalid actor reference | 404, ingress failed |
* TLSv1.3 (IN), TLS alert, unknown (628):
curl: (56) OpenSSL SSL_read: OpenSSL/3.3.2: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
exit=56No refusal carries an HTTP status, so a client library reports a transport error. When you debug a caller, look for the TLS alert first. The agentgateway error body is also less useful after authentication: it says ingress failed for an unknown actor and for a missing header.
One claim in older material is wrong for an installed cluster. The agentgateway router does authenticate callers. The installer adds the client certificate check and the allowlist to every agentgateway install that it makes. We also installed with an empty allowlist. The rule in the router configuration became source.spiffeId in [], and the router refused both callers that we tried.
The identity in the quickstart does not exist
The quickstart passes one identity to the installer: spiffe://cluster.local/ns/ate-system/sa/atenet-client. After the install, the namespace ate-system has no ServiceAccount with that name.
NAME AGE
ate-api-server 3m33s
ate-client 3m31s
ate-controller 3m31s
ate-idp-server 3m32s
atelet 3m30s
atenet-egress 3m20s
atenet-router 3m28s
default 4m18s
k8s-credential-provider 3m21skubectl ate proxy still works, because it does not run as a pod. It asks the cluster for a certificate with that identity, and it names a pod and a ServiceAccount that do not exist. A pod of yours cannot do that. To call the router from a pod with the quickstart identity, we first ran kubectl create serviceaccount atenet-client -n ate-system. Then we mounted a pod certificate from the pod identity signer.
Do not copy that into production. The quickstart identity is one name that every user of kubectl ate proxy shares. Give each calling service its own ServiceAccount, and list each SPIFFE ID in --ingress-allowed-spiffe-ids. Treat the right to request certificates in ate-system as the right to become any component there.
EgressPolicy and default deny
An actor with no EgressPolicy reaches nothing, and a policy opens named hosts and ports only, through three rule kinds and no CIDR rule.
Your agent runs a tool that downloads a package, and the tool reports that the host does not resolve. You open a shell in the actor and curl fails for every name you try. The network is not broken. The actor has no policy, so Substrate denies all of it.
When you finish this section, you can write an EgressPolicy for one actor and apply it with kubectl ate. You can also tell from the error which layer refused a connection.
The path of an outgoing connection
An actor does not sit on the pod network. Its sandbox has one link of four addresses, and the far end of that link belongs to the worker.
nameserver 169.254.17.1
search ate-demo-egress.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
…
inet 169.254.17.2/30 scope global dynamic eth0
…
default via 169.254.17.1 dev eth0 scope linkThree parts of Substrate handle what leaves that link:
- Firewall rules in the worker redirect every TCP connection to
atunnel, a process in the worker pod. Other protocols are rejected, except DNS to169.254.17.1. atunnelopens a mutual TLSCONNECTto the Serviceatenet-egress. It presents the certificate of the actor, and the target is the original IP and port.atenet-egressverifies the certificate and asksate-api-serverthat the actor is running or resuming. Then it reads the policy of that actor and decides.
The upstream host never sees the actor. In our run the test server reported the caller as 10.244.0.22, the pod IP of atenet-egress. Actor networking is IPv4 only at this version.
The policy is an API record, one per actor
EgressPolicy: the list of allow rules for one actor. It is a record in the Substrate API with the fixed name default, stored under the actor. It is not a Kubernetes object, so kubectl get does not show it.
You create the policy after the actor exists. A new actor does not copy the policy of its template. A harness that creates actors must therefore write a policy for each one, which is what kagent does (section 7.1).
A policy holds up to 256 rules, and the order of the rules has no meaning. Each rule is one of three kinds.
| Rule kind | What the gateway reads | Default port | Credential effects |
|---|---|---|---|
http | the Host header of each cleartext request | 80 | yes |
https | each request, after the gateway decrypts the TLS session | 443 | yes |
tlsPassthrough | the server name in the TLS handshake, once for each connection | none, you must list ports | no |
A rule has three fields: hostnames, ports, and effects. A hostname is a lowercase DNS name, or a name with * as its whole first label, or * alone. An IP address is not valid, and no rule kind takes a CIDR range. A rule also has no field for a path, a method, a model, or a tool. The policy decides which hosts an actor can reach. It does not decide what the actor asks them for.
This is the policy of our test actor f-main, as the API returned it. We wrote the three rules. The API added the default ports and the metadata.
metadata:
atespace: lab-f
createTime: "2026-11-12T10:45:59.339191794Z"
name: default
uid: 06d478d7-7a70-4ca0-91f8-4524c40ea9bc
updateTime: "2026-11-12T10:52:47.315685233Z"
version: "6"
rules:
- http:
hostnames:
- whoami.egress-target.svc.cluster.local
ports:
numbers:
- 80
- https:
hostnames:
- example.com
ports:
numbers:
- 443
- tlsPassthrough:
hostnames:
- example.org
ports:
numbers:
- 443Create and update a policy
The first policy of an actor needs only the rules.
kubectl ate create egress-policy f-main -a lab-f -f policy.yaml
kubectl ate get egress-policy f-main -a lab-f -o yamlAn update is stricter, and we got it wrong twice. The first time, we sent a file with only rules.
Error: manifest for actor "f-main" in atespace "lab-f" lacks the metadata.uid and metadata.version preconditions that "get egress-policy -o yaml" printsupdate egress-policy replaces the whole policy, and it needs to know which version you edited. Read the policy with -o yaml, keep metadata.uid and metadata.version, change the rules, and send the file back.
If the stored policy changed between your read and your update, the server refuses the update. The CLI then tells you to run get egress-policy -o yaml again. The CLI itself checks only that both values are in the file. Read the policy again and repeat the change. We did not run that case.
The second time, the file had a tlsPassthrough rule with no ports.
Error: failed to update egress policy for actor "f-main" in atespace "lab-f": rpc error: code = InvalidArgument desc = egress_policy.rules[2].tls_passthrough.ports: Required valueAn http rule without ports means port 80, and an https rule means port 443. A tlsPassthrough rule has no default. Write ports: {numbers: [443]} each time.
What a denied request looks like
The two dataplanes enforce the same policy and answer in different words. Learn both sets, because an agent sees these strings in its tool output.
| The actor does this | Envoy dataplane | agentgateway dataplane |
|---|---|---|
| Resolves any name, with no policy | NXDOMAIN | NXDOMAIN |
| Resolves a name that is not in its policy | NXDOMAIN | NXDOMAIN |
Queries another resolver, such as 8.8.8.8 | timeout | timeout |
| Sends HTTP to an IP, with no policy | connection reset | 403, actor egress policy denied |
| Sends HTTP to an IP that is not in its policy | 403, egress denied | 403, actor egress policy denied |
| Opens TLS to an IP, with no policy | handshake cut | handshake cut |
| Opens TLS with a server name that no rule lists | handshake cut | handshake cut |
| Sends a WebSocket upgrade to an allowed host | 403 | 403, WebSocket egress is not supported |
A name outside the policy never reaches the gateway, because the DNS relay answers first. Section 6.3 covers that relay. The gateway answers only when the actor already has an IP address.
HTTP/1.1 403 Forbidden
content-type: text/plain
content-length: 26
…
actor egress policy deniedOn Envoy, the same request from the same actor ended with curl: (56) Recv failure: Connection reset by peer. The Envoy text egress denied appears only when the actor has a policy and the request does not match it.
The Host header does not open another host
A policy that names hosts invites one attack: connect to an IP of your choice and claim an allowed name in the Host header. We tried it. The policy allowed only whoami.egress-target.svc.cluster.local. The actor connected to the IP of a second service, drip, and sent the allowed name.
$ curl -sS -m 8 -i -H 'Host: whoami.egress-target.svc.cluster.local' 'http://10.96.185.134/drip?n=1' | head -14
…
HTTP/1.1 200 OK
…
Hostname: whoami-5bdb4584f6-4gqz6The answer came from whoami, not from drip. The gateway took the name from the header, found it in the policy, and connected to that name itself. It ignored the IP that the actor had chosen. Both dataplanes did the same. An actor therefore reaches only the hosts that the policy names, whatever address it dials.
The reverse also holds. A policy names hosts, so it cannot pin an IP. If the name resolves to a new address tomorrow, the gateway follows it.
The DNS relay
The DNS relay answers only for names in the policy of the actor, so a denied host fails as NXDOMAIN before the actor opens a connection.
An agent reports Could not resolve host: example.com. You check CoreDNS, and it is healthy and has no record of the query. You resolve the name from a debug pod and it works. The actor never asked CoreDNS. It asked a resolver that Substrate runs for it, and that resolver read the policy first.
When you finish this section, you can explain each DNS failure inside an actor. You can also say how long a policy change takes to reach name lookups.
One resolver, and it reads the policy
The file /etc/resolv.conf in an actor names one server, 169.254.17.1. That address is the worker end of the link of the actor.
DNS relay: a resolver inside atunnel in the worker pod, with one instance for each actor. It forwards a query to the cluster resolver only when the name matches a hostname in a rule of that actor. The rule kind does not matter. For every other name it answers NXDOMAIN.
The relay marks a denial so that clients do not cache it, and it does not log the name that it denied. That is why your search of the cluster logs found nothing.
The same query from one actor, before and after its policy named the host:
$ dig +time=2 +tries=1 +noall +comments +answer whoami.egress-target.svc.cluster.local | grep -E 'status|IN'
…
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 58397
…
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 26542
…
whoami.egress-target.svc.cluster.local. 30 IN A 10.96.79.253Default deny covers cluster names too. An actor with no rule for the Kubernetes API cannot resolve kubernetes.default.svc.cluster.local. An actor is not a pod, and it gets no cluster DNS for free.
Another resolver does not help
A tool that distrusts the local resolver sometimes queries a public one. From an actor, that query gets no answer at all.
$ dig @8.8.8.8 +time=2 +tries=1 example.com | grep -E 'status|timed out|refused|unreachable' ; echo rc=$?
;; communications error to 8.8.8.8#53: timed outOnly DNS to the worker end of the link reaches the relay. A query to any other server is not DNS traffic for Substrate, and Substrate forwards no UDP out of the sandbox. The result was the same on both dataplanes, because this check happens in the worker and not in the gateway.
Expect the same from DNS over HTTPS. It is an HTTPS request to a host, and the policy decides it like any other host.
Short names against full names
Kubernetes users write short Service names every day, and inside a pod they work. Inside an actor a short name fails in a way that looks like a policy bug. We saw it in the kagent run.
The policy of the kagent actor allowed mock-llm.kagent.svc.cluster.local on port 8080. The search list of the actor was kagent.svc.cluster.local svc.cluster.local cluster.local. We asked for the model list four ways.
| Request from the actor | Result |
|---|---|
mock-llm.kagent.svc.cluster.local:8080 | 200, the model list |
mock-llm.kagent:8080 | 403 Forbidden |
example.com | bad address, the resolver answered NXDOMAIN |
kubernetes.default.svc.cluster.local | bad address |
The short name got a 403, not a DNS error. The resolver in the actor added a search suffix, the relay saw the full name, and the lookup succeeded. Then the HTTP request carried Host: mock-llm.kagent:8080. The gateway compares the Host header with the policy, and the policy holds the full name only.
A http-outside:
wget: bad address 'example.com'
…
E allowed-host:
{"object": "list", "data": [{"id": "mock-1", "object": "model", "owned_by": "lab"}]}
…
G kube-api:
wget: bad address 'kubernetes.default.svc.cluster.local'
H short-name:
wget: server returned error: HTTP/1.1 403 ForbiddenThe fix is on your side. Write full names in the policy, and give the agent full names in its configuration, its prompts, and its tool definitions. We did not test a rule for the short name. Full names worked in every run, so use them.
How fast a policy change reaches DNS
The relay keeps the policy of its actor in a cache for 5 seconds. A change to the policy therefore reaches name lookups a few seconds after the API accepts it. We polled a name every 2 seconds and changed the policy during the poll.
10:56:33 com=status: NXDOMAIN org=status: NXDOMAIN
10:56:35 com=status: NXDOMAIN org=status: NXDOMAIN
10:56:37 com=status: NXDOMAIN org=status: NXDOMAIN
10:56:39 com=status: NOERROR org=status: NOERROR
10:56:41 com=status: NOERROR org=status: NOERRORWe measured six policy changes on a second x86 server guest (16 vCPU, 23 GB): one added rule and two removed rules on each dataplane. In five of the six, the first sample with the new answer came within 2 s of the update. In the sixth it came within 3 s. Our poll interval was about 2 s, so we cannot give a finer number. Raw runs
One run did not fit. On the Envoy dataplane we added a rule for example.com, a name that the actor had never queried, and waited 12 seconds. Requests that finished 14 s and 16 s after the update still failed with Could not resolve host. A request at 18 s worked. We saw this delay once and we did not reproduce it, so we cannot name its cause.
Plan for the slow case, because many tools report a DNS failure once and stop. Write the policy before the actor makes its first request. When you add a host to a running actor, wait and retry for 20 seconds before you report a failure.
One behaviour we did not test, and you should know it from the code. When the relay cannot refresh the policy, it keeps the last policy that it read and answers from it. The gateway does the opposite and refuses requests when it cannot read the policy. A removed host can therefore keep its DNS answer during a control plane outage, and the connection still fails at the gateway.
TLS interception and the trust bundle
An https rule makes the gateway end the TLS session with a certificate that it mints, so the actor must trust the projected bundle, and tlsPassthrough leaves TLS intact.
Your agent calls an API over HTTPS, and the policy allows the host. The call fails with a certificate error that you cannot reproduce from your laptop. The host is fine. The actor talked to atenet-egress, which answered with a certificate from a private CA, and the client in the actor does not trust that CA.
When you finish this section, you can choose between an https rule and a tlsPassthrough rule for a host. You can also give an actor the trust bundle, and say where the CA private key lives on each dataplane.
Two rule kinds, two certificates
Both rule kinds allow TLS to a host. They differ in who holds the session.
With an https rule, the gateway ends the TLS session of the actor. It presents a certificate that it created for the requested name, reads each request, and applies the policy to each one. Then it opens its own TLS session to the real host and verifies that host against public roots.
With a tlsPassthrough rule, the gateway reads only the server name in the handshake. If a rule lists that name and port, the gateway forwards the bytes and never decrypts them.
We allowed example.com with https and example.org with tlsPassthrough, and we printed the certificate that the actor received from each.
issuer=CN=U7ICLCR3N66DBMBSAQNS5JHTHI
subject=
notBefore=Nov 12 10:48:04 2026 GMT
notAfter=Nov 12 11:08:04 2026 GMT
X509v3 Subject Alternative Name: critical
DNS:example.comissuer=C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
subject=CN=example.org
notBefore=Sep 26 22:05:05 2026 GMT
notAfter=Dec 25 22:13:27 2026 GMT| Question | https rule | tlsPassthrough rule |
|---|---|---|
| Certificate that the actor sees | minted by the gateway | the real certificate of the host |
| Trust that the actor needs | the projected bundle | its normal root store |
| When the policy is checked | for each request | once, at the handshake |
| Credential injection | yes | no |
| Gateway can read the request | yes, headers and body | no |
| A client that pins the server certificate | fails | works |
Our rule: use https for every host that needs a credential, and for every host where you want a decision for each request. Use tlsPassthrough for a client that pins certificates or brings its own client certificate. Know what you give up with it. The gateway decides once, and the connection then carries whatever the actor sends.
Give the actor the trust bundle
Without the bundle, a client in the actor rejects the minted certificate. With the bundle, the same request works.
$ curl -sS -m 15 -o /dev/null -w 'code=%{http_code} verify=%{ssl_verify_result}\n' https://example.com/
exit=60
code=000 verify=20
…
stderr: curl: (60) SSL certificate problem: unable to get local issuer certificate
…
$ curl -sS -m 15 -o /dev/null -w 'code=%{http_code} verify=%{ssl_verify_result} http=%{http_version}\n' --cacert /run/ate/egress-trust-bundle/trust-bundle.pem https://example.com/
exit=0
code=200 verify=0 http=2Trust bundle: a file that Substrate projects into the actor, with the interception CA of the cluster and a set of public roots. You ask for it in the ActorTemplate with a systemInfo volume.
volumes:
- name: system-info
systemInfo:
dataSources:
- trustBundle:
names:
- egress-mitm.ate.dev
- system-roots.ate.dev
path: egress-trust-bundle/trust-bundle.pemThe first name is the interception CA. The second is the set of public roots, so one file serves intercepted hosts and pass-through hosts. Our bundle held 149 certificates in 222,672 bytes.
Most clients do not read that path on their own. Setting SSL_CERT_FILE to the path was enough for curl in our run. Other runtimes read other variables, such as REQUESTS_CA_BUNDLE for Python requests and NODE_EXTRA_CA_CERTS for Node.js. Set them in the env of the template, not in the agent prompt.
Do not copy the bundle into the container image. The CA can rotate, and the projected file follows the rotation. A copy in an image does not.
The leaf certificate differs between the dataplanes
Both dataplanes sign with the same CA. The certificates that they mint do not look alike.
| Field of the minted certificate | Envoy dataplane | agentgateway dataplane |
|---|---|---|
| Subject | empty | CN=example.com |
| Subject alternative name | DNS:example.com, marked critical | DNS:example.com |
| Valid from | 5 minutes before the request | 1 January 1975 |
| Valid until | 20 minutes after the start | 1 January 4096 |
| Chain that the gateway sends | the leaf only | the leaf and the CA |
| curl error without the bundle | unable to get local issuer certificate | self-signed certificate in certificate chain |
On Envoy a separate process mints each leaf with the flag --leaf-cert-ttl=15m, and it dates the start 5 minutes into the past. On agentgateway the leaf is valid for 2,121 years.
For the client, both work. For you, the difference changes two things. First, the error text for a missing bundle differs, so search for both strings when you debug. Second, do not write a test that asserts a field of the leaf. A switch of dataplane changes every row of that table.
We prefer the short lifetime of the Envoy leaf. A leaf that stays valid for centuries makes the CA the only control. If the CA key is exposed, a rotation of the CA is the single way to stop a forged certificate.
Where the CA private key lives
The interception CA is the Secret egress-mitm-ca-pool in the namespace ate-system. Whoever holds its private key can present a trusted certificate for any host to every actor that trusts the bundle. So the place where that key is mounted matters more than any field of the leaf.
On the Envoy dataplane, the pod atenet-egress has three containers. The key is mounted into one of them, sdsmint, which serves a Unix socket and no network port. Envoy asks it for certificates over a Unix socket. The process that parses traffic from actors never holds the key.
sdsmint --uds-path=/var/run/sdsmint/sdsmint.sock --ca-pool-path=/run/ca-state/mitm-pool.json --leaf-cert-ttl=15m --log-level=info …On the agentgateway dataplane, the pod has one container, and that container mounts the key.
tls:
mode: dynamicCa
cert: /run/egress-mitm/tls.crt
key: /run/egress-mitm/tls.keyThe same process accepts connections from every actor, parses their requests, and holds the signing key. In our install that container also had no securityContext. The envoy container drops all capabilities and runs as a user other than root.
We do not know of an attack that reads the key through agentgateway. Our point is about the size of the loss if one appears. On Envoy a memory disclosure bug in the proxy exposes traffic. On agentgateway the same class of bug exposes the CA. On either dataplane, limit who can read Secrets in ate-system, because the Secret is the same.
Which upstream certificates the gateway trusts
Interception has a second TLS connection, from the gateway to the real host. The actor trusts the gateway through the bundle. The gateway must trust the host by itself. On both dataplanes it reads the roots from one file in its own container, /etc/ssl/certs/ca-certificates.crt. The agentgateway configuration names that file as backendTLS.root, and the Envoy configuration names it as trusted_ca. Substrate v1.0.0 has no flag and no documented setting for one more upstream CA.
So a host with a certificate from a private CA fails under an https rule. We ran a gateway in the cluster with such a certificate, and an actor called it through an https rule.
We measured this once on each dataplane, on a second x86 server guest (16 vCPU, 23 GB), with 30 requests each. With the default install, 0 of 30 requests succeeded on either one. On agentgateway the actor got 503 with the body upstream connection failed, and the gateway logged invalid peer certificate: UnknownIssuer. On Envoy the actor got 503 with upstream connect error or disconnect/reset before headers. reset reason: remote connection failure. The Envoy log had CERTIFICATE_VERIFY_FAILED. Raw runs
The fix that worked is a mount over the root file. We put the public roots and our CA in one ConfigMap, 122 certificates in all, and mounted it in the proxy container of atenet-egress.
[
{"op": "add", "path": "/spec/template/spec/volumes/-", "value": {"name": "extra-roots", "configMap": {"name": "egress-extra-roots"}}},
{"op": "add", "path": "/spec/template/spec/containers/0/volumeMounts/-", "value": {"name": "extra-roots", "mountPath": "/etc/ssl/certs/ca-certificates.crt", "subPath": "ca-certificates.crt"}}
]With the patch, the same requests returned 200 on both dataplanes, 30 of 30. The patch is outside the installer, and it does not last. After a later ate-setup deploy ate-system run, the proxy container had no such mount. Apply the patch again after each installer run, and test one request to the private host each time.
The actor never sees the private CA. It sees a leaf for the host name that the interception CA minted, and it verifies that leaf with the projected bundle.
What a new intercepted connection costs
We measured the same private host on the same guest (16 vCPU, 23 GB), with a request of 60 bytes. With a new connection for each request, the median was 618 ms on agentgateway and 614 ms on Envoy, with n=10 each. The TLS handshake with the interception endpoint took 606 and 600 ms of that. On one reused connection, the median was 10.0 and 11.9 ms, with n=30 each. An http rule to a host in the cluster cost 23 to 28 ms for a new connection. Raw runs
So interception is cheap for each request and costly for each connection, about 0.6 s on both dataplanes. A client that opens one HTTPS connection for each call pays it each time. Keep connections open, or use a client with a pool. We did not find which step inside the handshake takes the time. agentgateway with Substrate has the full table.
Credential injection
The gateway replaces a placeholder header with a secret that the actor never holds, and an upstream that returns its request headers gives that secret back to the actor.
An agent needs an API key to call a model or a code host. The agent also runs code that a model wrote. Substrate can copy the memory and the files of its sandbox to an object store at each suspend. You do not want the key in that sandbox, in its environment, or in its snapshots.
When you finish this section, you can write a rule that injects a credential, and grant an atespace access to a Kubernetes Secret. You can also name the cases where the swap fails and the one case where the secret reaches the actor.
The swap in one request
Credential injection is an effect on an http or https rule. The rule names a header and a secret. The actor sends that header with any value, and the gateway overwrites the value before the request leaves the cluster.
Placeholder header: the header that the actor sends with a value that is not a secret, such as Authorization: placeholder-from-actor. Its presence asks for the swap. Its value is thrown away.
This is the rule file from our test. It has three headers, so that one request can exercise each outcome.
rules:
- http:
hostnames: ["whoami.egress-target.svc.cluster.local"]
effects:
replaceHeaders:
- header: Authorization
prefix: "Bearer "
credentialUri: ate-secret://k8s.io/default/cred-ns/api-token/token
- header: X-Other-Token
credentialUri: ate-secret://k8s.io/default/cred-ns-not-granted/api-token/token
- header: X-Missing-Token
credentialUri: ate-secret://k8s.io/default/cred-ns/does-not-exist/tokenEach entry has a header, an optional prefix, and one source: a credentialUri, or an actorJwt that section 5.3 covers. A rule takes up to 16 entries.
The upstream in our test prints each request that it receives. The actor sent the placeholder, and this is what arrived.
$ curl -sS -m 8 -i -H 'Authorization: placeholder-from-actor' http://whoami.egress-target.svc.cluster.local/ | grep -i -E 'HTTP/|authorization|x-forwarded|RemoteAddr|egress'
…
HTTP/1.1 200 OK
RemoteAddr: 10.244.0.22:36554
GET / HTTP/1.1
Host: whoami.egress-target.svc.cluster.local
Authorization: Bearer lab-granted-marker-1794480266The environment of the actor held no secret. We listed it, and we searched /run, /etc, and /tmp for the value. Nothing matched. A secret that never enters the sandbox cannot be in a snapshot of it.
Our test used an http rule to a Service in the cluster. With http, the credential leaves the gateway in cleartext. For a real API, put the effect on an https rule.
The ate-secret:// reference and the bundled provider
Credential provider: a gRPC service that turns a reference into a secret value. The gateway picks the provider from the host part of the reference and sends it the reference and the identity of the actor. The provider decides if that actor gets that secret.
Substrate v1.0.0 installs one provider when you pass --credential-provider='{"name":"k8s.io"}'. It runs as the Deployment k8s-credential-provider in ate-system, and it reads Kubernetes Secrets. Its references have this form:
ate-secret://k8s.io/default/<namespace>/<secret>/<key>The provider reads the Secret for each call that it gets and keeps no copy. How soon a rotated Secret takes effect depends on the dataplane, because the gateway decides how often it calls the provider.
| Dataplane | What the gateway does | A rotated Secret takes effect |
|---|---|---|
| Envoy | It calls the provider for each request that carries the header, and it keeps no copy. | on the next request, or the one after it: 1.0 to 1.6 s in our run |
| agentgateway v1.7.0 | It keeps each fetched credential for 300 seconds, for one actor and one reference. ate-setup sets no other value. | after the time that is left of those 300 seconds: 100.6 to 280.5 s in our run |
The agentgateway row covers every kagent install, because that distribution runs agentgateway. We read both rows in the code first, and then we measured them.
We measured three changes of one Secret on each dataplane, on a second x86 server guest (16 vCPU, 23 GB). One running actor had one http rule with the effect. It sent one request about each second to an echo service in the cluster. We recorded when that service first saw each new value. Raw runs
We measured these lags in that run on the same guest (16 vCPU, 23 GB), with n=3 changes for each dataplane. On agentgateway the lag was 280.5, 200.9, and 100.6 s. In those times 233, 168, and 84 requests still carried the old value. On Envoy the lag was 1.0, 1.0, and 1.6 s, and one request in all carried an old value. No request failed on either dataplane, in 759 and 275 requests. Raw runs
rotation wait_before_s secret_changed_epoch first_seen_epoch lag_s old_values_after_change
1 5 1794493351.752 1794493632.272 280.5 233
2 100 1794493732.380 1794493933.328 200.9 168
3 200 1794494133.509 1794494234.122 100.6 84The three lags on agentgateway are not random. The upstream first saw a new value at three moments that were 301 s apart. So the gateway fetched the credential again each time its copy was 300 s old. The lag after a change is the time left until that moment. It can be any value from 0 to about 300 s. On Envoy our probe sent one request each second, so we cannot state a lag below 1 s.
During a change the upstream saw the old value or the new one. It never saw an empty header, and it never saw the placeholder of the actor. That is why no request failed. It also means that a secret which you revoke stays in use on agentgateway for up to 5 minutes. Revoke the old value at the upstream when you must stop its use at once. When you rotate without an incident, keep the old value valid at the upstream for 5 minutes after the change.
We tested one Secret and one actor. We did not test whether a restart of atenet-egress forces a new fetch.
On Envoy, the cost is one call to the provider and one read of the Kubernetes API for each request that carries the header. On agentgateway, most requests make no call to the provider.
The provider starts with no access. A ConfigMap, k8s-credential-provider-namespace-policy, maps each atespace to the namespaces whose Secrets its actors can use. The install creates it empty.
policies:
- atespace: lab-f
allowedNamespaces:
- cred-nsThe provider reads this file once, when it starts. We restarted the Deployment after our edit, and the grant worked. The grant is wide: every actor in the atespace can name every Secret in the namespace. Keep one namespace that holds only the Secrets that agents are allowed to use.
The help text of the installer says that a provider requires the Envoy dataplane. In our run the installer accepted k8s.io with agentgateway, and injection gave the same result on both.
When the swap does not happen
| The actor sends | The reference points to | Envoy dataplane | agentgateway dataplane |
|---|---|---|---|
Authorization with a placeholder | a Secret in a granted namespace | 200, upstream gets the secret | 200, upstream gets the secret |
X-Other-Token with a placeholder | a Secret in a namespace with no grant | 403, egress denied | 403, actor egress policy denied |
X-Missing-Token with a placeholder | a Secret that does not exist | 403, egress denied | 403, actor egress policy denied |
| no credential header | any | 200, request passes unchanged | 200, request passes unchanged |
The two 403 cases return the same body on purpose. A caller cannot use the answer to learn which Secrets exist. You cannot either, so check the grant and the Secret name by hand.
The last row needs care. The effect runs only when the header is present. Without the header, the gateway fetches nothing and forwards the request as it is. The host is still allowed, so the request arrives without a credential.
$ curl -sS -m 8 -i http://whoami.egress-target.svc.cluster.local/ | grep -i -E 'HTTP/|authorization'
…
HTTP/1.1 200 OK
GET / HTTP/1.1In practice the symptom is a 401 from the API, not an error from the gateway. A tool that builds its own request, such as a plain wget in a shell, sends no placeholder and gets no credential. We saw this in the kagent run: a wget from the actor reached the model host with three headers and no authorization.
The upstream that returns headers
Look again at the second listing of this section. The actor ran that curl, and the response body contained Authorization: Bearer lab-granted-marker-1794480266. Our test upstream returns each request to its sender, so the actor read the secret that it was never given.
The gateway cannot prevent this. It controls the request, not what the upstream does with it. The consequence is a rule for you: a credential is only as private as the hosts in its rule.
- Name the exact host that owns the credential. Never attach a credential to a rule with
*in its hostnames. - Do not attach a credential to a host that the actor can make return its headers, such as a debug endpoint or a request inspector.
- Give the agent a credential with the smallest scope that the API offers. Treat it as known to the agent.
The logs were clean in our run. The injected value appeared in no log line of the gateway or the provider, on either dataplane. The agentgateway egress pod runs with RUST_LOG=debug and prints the headers of each request. It printed authorization as Sensitive. For an injected X-Api-Key, it printed the placeholder that the actor sent and never the secret. A value that the actor itself sends in a custom header is logged in clear there.
What kagent does with it
kagent uses this feature for model keys, so you get it without writing a rule. For each session, kagent writes a policy with one rule for the model host of the ModelConfig.
- http:
effects:
replaceHeaders:
- credentialUri: ate-secret://k8s.io/default/kagent/kagent-openai/OPENAI_API_KEY
header: authorization
prefix: 'Bearer '
hostnames:
- mock-llm.kagent.svc.cluster.local
ports:
numbers:
- 8080kagent also sets OPENAI_API_KEY=kagent-credential-injected in the environment of the actor. The model client reads that variable and sends it as the authorization header, which is the placeholder. Our mock model server received Bearer sk-mock-not-a-real-key, a test value that we made up, on 10 of 10 calls.
We ran kagent against a mock model over HTTP in the cluster. We did not test a real model host over HTTPS. Section 7.1 has the rest of that policy.
The dataplane choice: Envoy or agentgateway
agentgateway does the work of the Envoy dataplane in 38 MB and two containers, but it holds the interception CA key in the process that actors talk to.
The installer has a flag, --atenet-dataplane, with two values and no advice. If you install kagent, the choice is already made for you: it runs agentgateway. Either way you now operate one of two proxies. They do not behave the same when a request is refused or a rule is removed.
When you finish this section, you can choose a dataplane for a cluster and give the reason. You can also say what happens to an open connection after you remove its rule.
One flag changes both gateways
Dataplane: the proxy software inside the pods atenet-router and atenet-egress. The flag --atenet-dataplane takes envoy, the default, or agentgateway. One value applies to both pods.
The Substrate API does not change with the flag. Actors, policies, and kubectl ate work the same. What changes is the code that enforces a policy.
On the Envoy dataplane, two pieces of Substrate code decide. A Substrate process beside Envoy authenticates the actor, decides each HTTP request, and supplies the rules. A Substrate module that Envoy loads into its own process decides TLS connections by server name. On the agentgateway dataplane, policy code inside agentgateway v1.7.0 makes it. Two implementations of one policy explain every difference in this section.
You can switch a running cluster. Run deploy ate-system again with the same flags and the new value.
We measured the switch from Envoy to agentgateway once, on a second x86 server guest (16 vCPU, 23 GB). The command took 32 s and exited with 0. Two running actors stayed in ACTOR_STATE_RUNNING on their workers through the switch. We did not send requests during those 32 seconds, so we cannot say how many would fail. Raw runs
The full comparison
We captured the same inventory and ran the same probes on each dataplane, on one cluster.
| Envoy dataplane | agentgateway dataplane | |
|---|---|---|
Containers in atenet-router | atenet-router, envoy | agentgateway |
Containers in atenet-egress | envoy, ext-proc, sdsmint | agentgateway |
| Router listeners | 8443, 8444 | 8443, 8444 |
| Router Service ports | 443, 8444 | 443, 8444, 80, 8081, 15020 |
| Caller with no client certificate | connection cut | TLS alert certificate required |
| Caller with an identity not on the list | TLS alert certificate unknown | connection reset |
| Caller with the allowed identity | 200 on 443 and through 8444 | 200 on 443 and through 8444 |
| Unknown actor, or no routing header | 404, with the reason in the body | 404, ingress failed |
| HTTP from an actor with no policy | connection reset | 403, actor egress policy denied |
| HTTP to a host outside the policy | 403, egress denied | 403, actor egress policy denied |
| Minted certificate | 20 minutes, empty subject | year 1975 to 4096, chain has the CA |
| Interception CA private key | in sdsmint only | in the agentgateway container |
Container securityContext | drops all capabilities | not set |
| Egress log level | default | RUST_LOG=debug |
| Egress log lines after the same test script | 1,369 | 6,477 |
| Idle memory, router and egress | about 91 MB in 5 containers | about 38 MB in 2 containers |
| New request after a rule is removed | refused after 3 to 6 s | refused within 1 to 2 s |
We measured idle memory with one sample for each container, soon after start, on a second x86 server guest (16 vCPU, 23 GB). The five Envoy dataplane containers held 25.6, 21.6, 22.8, 12.0, and 8.9 MB. The two agentgateway containers held 19.9 and 17.8 MB. In bursts of up to 300 resumes, the Envoy process of the router used 0.12 to 0.19 cores on average. It did not set the rate, as section 4.5 shows. We did not load agentgateway, and we did not compare the two dataplanes under load. Raw runs
Three rows need a comment.
The log row is a volume and a content matter. At debug level, agentgateway prints the headers of each request. In our run it masked authorization and it never printed an injected secret. It did print, in clear, a custom header value that the actor sent. Store those logs with the same care as request headers.
The securityContext row is as the installer wrote it. We did not try to add one to the agentgateway containers.
The last row favours agentgateway, and the next subsection puts it in context.
An open connection outlives its rule
You remove a host from a policy because you want the actor to stop talking to it. The gateway applies the new policy to new work only. This is a known behaviour of v1.0.0, and both dataplanes have it.
Two mechanisms are behind it. The gateway keeps the policy of an actor in a cache, 10 seconds by default on Envoy, so a change takes effect late. And a decision is not revisited: an http or https decision holds for the request, and a tlsPassthrough decision holds for the connection.
We measured each case once for each dataplane, on the second x86 server guest (16 vCPU, 23 GB), with a poll about every 2 seconds. Zero is the moment the update returned. Raw runs
| After the rule is removed | Envoy dataplane | agentgateway dataplane |
|---|---|---|
| New DNS lookup of the host | NXDOMAIN at the first sample, under 2 s | NXDOMAIN within 3 s |
| New HTTP request to the known IP | 200 at 1 s and 3 s, 403 from 6 s | 403 at the first sample, under 2 s |
| New pass-through connection | refused at the first sample, under 2 s | refused at the first sample, under 2 s |
| HTTP response that had already started | ran to its end, 39 s after removal | ran to its end, 18 s after removal |
| Second request on an open pass-through connection, 20 s later | 200 | 200 |
Next request on a reused HTTP/1.1 connection of an http rule | 200 at 6.5 s, 403 from 8.5 s | 403 from the first request, at 0.8 s |
Next request on a reused HTTP/2 connection of an https rule | 200 at 4.6 s, 403 from 6.6 s | 403 from the first request, at 0.7 s |
We measured the last two rows in a later run on the same guest (16 vCPU, 23 GB), once for each dataplane and each protocol. One curl process in the actor sent 20 requests on one connection, one every 2 s. We removed the rule about 13 s after the start. The start time of each run has a resolution of 1 s, so each time in those rows can be up to 1 s later. Raw runs
HTTP/1.1 200 OK
Date: Thu, 12 Nov 2026 10:57:50 GMT
HTTP/1.1 200 OK
Date: Thu, 12 Nov 2026 10:58:13 GMT
end=10:58:17The 39 s and the 18 s mark the ends of our test responses. In no run did the gateway end an open connection, so we have no upper limit to give you. An actor that keeps one pass-through connection open keeps its access.
A reused connection is a different case, and it does not keep a removed rule alive. Under an http or an https rule, both dataplanes check the policy for each request. The next request on the open connection got 403, over HTTP/1.1 and over HTTP/2. The delay was the same as for a new request: about 1 s on agentgateway, and 5 to 9 s on Envoy. A new connection after the removal failed earlier, because the name did not resolve.
200 http=1.1 new_conns=0 lport=50509 t=0.012208
200 http=1.1 new_conns=0 lport=50509 t=0.008836
403 http=1.1 new_conns=0 lport=50509 t=0.007677
403 http=1.1 new_conns=0 lport=50509 t=0.009808
…So the rule is: a removal stops requests, and it does not stop a response that is streaming or a pass-through connection. Removing a rule is therefore not a way to cut off an actor now. For an http rule, remove the rule and then suspend the actor.
We measured that cut-off once on each dataplane, on the same guest (16 vCPU, 23 GB). The actor held one open HTTP response from a host that writes one chunk each second. An asynchronous suspend actor returned in 0.64 s on agentgateway and in 0.54 s on Envoy. The upstream saw the connection gone 2.2 s and 1.3 s after the suspend call began. It writes once each second, so the real close can be up to 1 s earlier. Raw runs
The connection does not come back. After a resume, no new chunk reached the actor. The curl processes were still in the process list of the actor, restored from memory with sockets that no longer work. An application that you suspend must be able to reconnect. We used an http rule in this test, and we did not repeat it with a tlsPassthrough connection.
Our verdict
Choose Envoy, the default, for a cluster where actors run code that you do not trust and policies have https rules. The reason is the CA key. Envoy keeps it away from the process that parses actor traffic, and section 6.4 explains what that key can do. Envoy also gives better error bodies at the router. It is the only dataplane that accepts the experimental hook for an extra egress filter.
Choose agentgateway in three cases. You run kagent, which installs it. You run small clusters where 53 MB for each cluster matters. Or you already operate agentgateway and want one proxy to know. Section 7.2 covers that last case.
If you choose agentgateway, do three things. Limit who can read Secrets in ate-system and who can open a shell in atenet-egress. Send the egress logs to a store with access rules. And set RUST_LOG=info on the egress Deployment.
We measured that setting with 100 requests at each level, on the same guest (16 vCPU, 23 GB), in one run. At debug, the installer default, the gateway wrote 4,380 lines and 1,590,006 bytes. In 200 of those lines it printed the value of a custom header that the actor sent. At info it wrote 100 lines and 46,300 bytes, one line for each request, and no header value. At warn it wrote no line. All requests returned 200 at every level. Raw runs
kubectl -n ate-system set env deploy/atenet-egress RUST_LOG=infoThe command restarts the egress pod. The installer writes debug again at its next run, so the setting is yours to keep. We did not change the level of atenet-router, and we did not check that info logs enough to debug a denied request.
Do not choose on speed. One allowed request took 0.037 s on Envoy and 0.035 s on agentgateway in our probes, and one request each proves nothing about load.
Integrations
kagent does the suspending that Substrate leaves to you, and agentgateway can carry the traffic in both directions.
kagent 1.0 on Substrate
kagent makes each Session one actor, resumes it for every turn, and suspends it after, so a turn pays half a second beside the model and keeps only files.
You install kagent 1.0 and send your agent a message. The answer comes back in about a second. Then you run kubectl get pods, and you see one worker pod and no pod for your agent. The agent is an actor, and kagent suspended it again before you looked.
When you finish this section, you can install kagent 1.0, write the three objects that make an agent, and predict what one turn costs. You can also say what a kagent snapshot keeps and which hosts the agent can reach.
We ran kagent v1.0.0. kagent v1.0.1 came out on the day of our run, 2026-11-12, and we did not test it. We ran it twice. In the first run the model was a mock server in the cluster. Those times exclude model time and show the cost of Substrate alone. In the second run the model was a small open model on CPU. The subsection on a turn with a real model reports it, and every other number in this section is from the first run.
The install, as we ran it
The documented CLI path worked with no fix. We used a kind cluster on Kubernetes 1.37 and one command. We found no document of kagent v1.0.0 that requires that Kubernetes version, and we tested no other.
| Step | Seconds |
|---|---|
kind create cluster --name kagent --image kindest/node:v1.37.0 | 26.1 |
| download the kagent CLI v1.0.0 | 5.5 |
kagent --config kagent-config.yaml install --namespace kagent | 216.1 |
We measured each step once, on a second x86 server guest (16 vCPU, 31 GB). The install command took 216 s and ended with exit code 0. Raw runs
The install command reads one small file.
install:
kagent:
valuesFiles: [kagent-values.yaml]kagent does not use a Substrate that you installed before. It installs its own distribution of Substrate, from images named ghcr.io/kagent-dev/substrate/*:v1.0.0. The install made six Helm releases: kagent-crds, substrate-crds, substrate-podcert, substrate-atelet, substrate, and kagent. In this distribution, atenet-router and atenet-egress each run one agentgateway v1.7.0 container. It has no Envoy.
The Substrate charts are not in the kagent repository. The installer pulls them from the chart registry of the kagent project. The kagent code builds against the kagent copy of the Substrate repository at v1.0.0. The installer also sets four Substrate values that change how the install behaves.
| What the kagent installer sets | Effect |
|---|---|
The preview features PerTemplateSnapshotLocation and GoldenEgressPolicy | Both are on. A plain ate-setup install has them off. |
Two bootstrap owners: the identity of the kagent controller, and ate-client | The kagent controller owns the whole install, as kubectl ate does. |
| The ingress allowlist of the router: the SPIFFE ID of the kagent controller | Only the kagent controller can send requests to actors. The atenet-client identity of an ate-setup install is not in the list. |
A namespace policy of the credential provider: atespace kagent to namespace kagent | Actors in the atespace kagent can get Secrets of the namespace kagent. |
The install created one WorkerPool, kagent-default, with one worker pod. It created no default Harness. Advice that tells you to select a Harness named kagent-default describes a later build. At v1.0.0 you write the Harness.
The three objects you write
An agent at v1.0.0 is three Kubernetes objects in the group api.kagent.dev/v1alpha3. YAML with the group kagent.dev is older than 1.0 and does not apply.
…
apiVersion: api.kagent.dev/v1alpha3
kind: Harness
metadata:
name: lab-harness
namespace: kagent
spec:
kagent: {}
substrate:
workerPoolRef:
name: kagent-default
snapshotPolicy:
location: s3://ate-snapshots/kagent/
---
apiVersion: api.kagent.dev/v1alpha3
kind: AgentTemplate
metadata:
name: lab-template
namespace: kagent
spec:
description: Lab agent that runs shell commands for the Substrate manual experiments
modelConfig:
name: mock-model
systemPrompt: You are a lab agent. Use the bash tool when the user asks you to run a command.
---
apiVersion: api.kagent.dev/v1alpha3
kind: Agent
metadata:
name: lab-agent
namespace: kagent
spec:
template:
ref:
name: lab-template
harnessRef:
name: lab-harnessHarness: the runtime that executes the agent, with the WorkerPool and the snapshot location that it uses. kagent: {} selects the engine of kagent. The other types are codex, claude, and byo, and we did not test them.
AgentTemplate: the model, the prompt, and the tools. It has no Substrate field.
Agent: the pair of one AgentTemplate and one Harness. kagent compiles each revision of that pair into one ActorTemplate through the Substrate API. Ours was lab-agent-7f643ac37f02.
The Agent was Ready about 24 s after we applied it. The golden snapshot build used 9.84 s of that time. Substrate has no Actor CRD and no ActorTemplate CRD, so kubectl get shows neither object. Use kubectl ate.
One Session is one Actor
Session: one conversation with an Agent. kagent creates one actor for it, named session-<uuid>, in the atespace kagent. The atespace has the name of the Kubernetes namespace of the Agent.
The actor is created in ACTOR_STATE_SUSPENDED, and kagent does not resume it. The first message does. A Session create took 0.39 s at the median over 20 creates.
The log of ate-api-server shows the cycle. These lines are one Session: the create, then two turns that were 4 s apart.
2026-11-12T10:20:35.633350007Z session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1 create suspended
2026-11-12T10:20:36.067645235Z session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1 resume resuming
2026-11-12T10:20:36.485975028Z session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1 resume running
2026-11-12T10:20:36.862693911Z session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1 suspend suspending
2026-11-12T10:20:36.995534634Z session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1 suspend suspended
2026-11-12T10:20:41.387075902Z session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1 resume resuming
2026-11-12T10:20:41.796382831Z session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1 resume running
2026-11-12T10:20:42.143125306Z session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1 suspend suspending
2026-11-12T10:20:42.270402046Z session-01a1cf46-0786-748f-ad16-cc3bb0e1f2e1 suspend suspendedAll 98 turns of our run had this shape: resume, run, suspend. Substrate does not suspend an idle actor, as SuspendActor and ResumeActor shows. kagent does that work for you, after every turn.
What one turn costs
We measured 98 turns on the same second x86 server guest (16 vCPU, 31 GB), with a mock model. The resume took 0.380 s at the median, from 0.216 to 0.542 s. The suspend took 0.142 s at the median over 99 suspends, from 0.120 to 0.398 s. A whole turn took 0.726 s in the cluster and 1.224 s at the client, over 20 turns spaced 4 s apart. Raw runs
No warm turn exists. Twenty turns with no pause took 0.738 s at the median, against 0.726 s for the spaced turns. The uptime inside the sandbox read 0.6 to 0.9 s on every turn. So you pay about 0.52 s of resume and suspend on each turn, and a real model adds its own time to that. The next subsection measures both.
A files-only resume is this fast only because the kagent harness starts fast. Every such resume is a new start of the image. We ran a Python agent at the same fidelity on the same guest, on the cluster of the Part 9 demos. It needed 4.5 to 5.1 s for each turn after a suspend. Snapshot fidelity compares the cases.
One of the 98 turns, turn 3 of the first session, took 6.56 s, with a gap of 5.5 s before the resume. We did not find the cause.
A turn with a real model
The mock model answers at once, so it hides the largest part of a real turn. We ran the same cluster again with a real model. It was qwen2.5, an open model, in two sizes. The model server was Ollama 0.40.16 in a container on the guest, with 12 CPUs, one request at a time, and no GPU.
| Model | Size | Tokens per second, 5 requests | Correct tool call, 10 requests |
|---|---|---|---|
qwen2.5:3b | 1.9 GB | median 7.1, from 6.9 to 7.3 | 10 of 10 |
qwen2.5:1.5b | 986 MB | median 13.9, from 13.1 to 14.3 | 9 of 10 |
This is a small model on slow hardware, and we chose it because both graphics cards of the server were in use by another team. A model on a GPU or a hosted model gives other model times. Read the model seconds below as the time of our machine. The Substrate seconds do not depend on the model.
Each turn asked the agent to run one command with its bash tool and to report one number. So a turn is two model requests: one that returns the tool call, and one that reads the result.
We measured 50 turns on the same second x86 server guest (16 vCPU, 31 GB). Each model ran 20 turns in one Session and the first turn of 5 new Sessions. Raw runs
| Series | Turns | Completed | Turn at the client | Model requests | Resume | Suspend | Model share |
|---|---|---|---|---|---|---|---|
| 3b, one Session | 20 | 20 | median 17.1 s, from 11.8 to 19.2 | 15.9 s | 0.46 s | 0.24 s | 0.93 |
| 3b, first turn of a new Session | 5 | 5 | median 14.4 s, from 13.4 to 16.4 | 13.0 s | 0.45 s | 0.17 s | 0.92 |
| 1.5b, one Session | 20 | 20 | median 11.0 s, from 10.0 to 19.7 | 9.8 s | 0.47 s | 0.23 s | 0.89 |
| 1.5b, first turn of a new Session | 5 | 4 | 12.2, 13.2, and 13.4 s for the three clean turns | 7.2 to 12.3 s | 0.46 s | 0.16 s | see below |
Every one of the 49 completed turns again had the shape resume, run, suspend. The resume and the suspend took about 0.7 s together, as with the mock model. The turn grew from 1.2 s at the client to 11 to 17 s, and the model was the difference. The resume and suspend columns are from the log of ate-api-server. For the last turn of each long series, the saved log held no resume record, so those two medians have 19 values.
Three turns of the 1.5b series sent three model requests in place of two. Two more turns of that series had about 5 s that neither the model nor Substrate accounts for. We did not find the cause.
One turn did not end
In the third new Session of the 1.5b series, the model did not stop. It had produced 2,800 tokens when we restarted the model server, 7 minutes and 40 s after the request began. The kagent CLI gave up after 300.13 s with context deadline exceeded and exit code 1.
Nothing ended the turn before the client did. The Session actor was ACTOR_STATE_RUNNING 193 s into the turn, and it held its worker CPU for all that time. The next turn waited behind the same model request, because the server took one request at a time. That turn completed after 189.8 s.
This is one event with one small model, and a larger model is less likely to run away. The cost of the event does not depend on the model. An actor that waits for a model is RUNNING, and kagent suspends it only when the turn completes. Nothing bounds a turn by default. Two settings of the ModelConfig do, and we tested both in a later run.
Two settings that bound a turn
The ModelConfig of kagent v1.0.0 has three fields for this under spec.openAI: maxTokens, maxCompletionTokens, and timeout. The timeout is in seconds, and it is the timeout of the HTTP client that calls the model. The two token fields exclude each other. The Agent, the AgentTemplate, and the Harness have no field for the seconds of a turn or for the number of model calls. The kagent CLI has --timeout, with a default of 5 minutes.
apiVersion: api.kagent.dev/v1alpha3
kind: ModelConfig
metadata: {name: w-tok, namespace: kagent}
spec:
provider: OpenAI
model: qwen2.5:1.5b
apiKeySecret: kagent-openai
apiKeySecretKey: OPENAI_API_KEY
stream: true
openAI:
baseUrl: http://ollama.kagent.svc.cluster.local:11434/v1
maxTokens: 128
…
apiVersion: api.kagent.dev/v1alpha3
kind: ModelConfig
metadata: {name: w-time, namespace: kagent}
spec:
provider: OpenAI
model: qwen2.5:1.5b
apiKeySecret: kagent-openai
apiKeySecretKey: OPENAI_API_KEY
stream: true
openAI:
baseUrl: http://ollama.kagent.svc.cluster.local:11434/v1
timeout: 20We first tried to get the runaway back, and it did not come. The first turns of 10 new Sessions on the 1.5b agent with no bound all completed, in 5.9 to 11.5 s. So we forced a long answer with a prompt that asks for the numbers 1 to 3000, one on each line. Sent to the model server directly, that prompt was still producing text when our request ended at 75 s. An earlier prompt asked for one word 5000 times, and the model stopped by itself in 7 of 7 turns.
We measured each row below on the same second x86 server guest (16 vCPU, 31 GB), after a restart of the guest. Every turn was the first turn of a new Session with the count prompt and the 1.5b model. Raw runs
| Setting | Turns | Result | Turn at the client | The actor and the model afterwards |
|---|---|---|---|---|
maxTokens: 128 | 3 | TASK_STATE_COMPLETED, with 128 output tokens and an answer that ends at 46 | 15.6, 19.0, and 19.4 s | ACTOR_STATE_SUSPENDED at the end of each turn |
timeout: 20 | 3 | TASK_STATE_FAILED, and the CLI ended with exit code 1 | 20.8, 20.9, and 21.2 s | ACTOR_STATE_SUSPENDED 5 s later, with the model server at 0% CPU |
no bound, and --timeout 60s at the CLI | 1 | the CLI ended at 60.12 s with context deadline exceeded | not ended | ACTOR_STATE_RUNNING in all 8 samples over 240 s, with the model server at about 1,200% CPU |
With timeout: 20, the task failed with this text: llm error response (code STREAM_ERROR): "context deadline exceeded (Client.Timeout or context cancellation while reading body)". The model server logged each of those three requests at 19.9 s. The Session was still usable. The next turn in one of those Sessions completed in 17.1 s, in one try.
Neither bound broke a normal tool turn. With maxTokens: 128, 5 of 5 tool turns completed, in 5.5 to 8.0 s. With timeout: 20, 5 of 5 completed, in 5.8 to 12.1 s. On our slow model server, a tool turn of 12.1 s is close to a bound of 20 s. Choose the seconds from the speed of your model.
The row with no bound shows what a client timeout does not do. After the CLI gave up, the model request ran on for 10 minutes and 10 s, until we restarted the model server. A turn on a new Session waited behind it and reached the CLI default at 300.13 s. A second turn on the first Session returned resource conflict after 0.13 s. The timeout of the client stops the client. It does not stop the model call, and it does not suspend the actor.
We did not see a runaway happen by itself while a bound was set, and we did not test maxCompletionTokens.
Our position: set timeout on every ModelConfig, because it also ends a model call that stalls. Add maxTokens when a cut answer is better for your users than a failed turn. Do not count the client timeout as a bound. Then count how many Sessions can wait for the model at one time, because each one holds 1 CPU of a worker. The subsection on parallel turns below has that arithmetic.
What the snapshot keeps
kagent asks Substrate for the files-only fidelity, SNAPSHOT_FIDELITY_VOLUMES, and it suspends in the synchronous mode. The snapshot is in the object store before the turn ends. It holds the directory /data and nothing else.
In turn 1 our agent wrote one file under /data, wrote one file under /tmp, and started a background sleep. Turn 2 looked for all three.
note=hello-from-turn1
scratch=cat: can't open '/tmp/scratch.txt': No such file or directory
bg=
uptime=0.74
PID ELAPSED COMMAND
1 0:01 /app
…The file under /data survived. The file under /tmp and the background process did not, and PID 1 was a new process. Write to /data everything that the next turn needs. Snapshot fidelity compares this mode with a memory snapshot.
The three egress rules that kagent writes
kagent writes one egress policy for each actor through the Substrate API. Our cluster had no Kubernetes EgressPolicy object at all.
…
rules:
- http:
effects:
replaceHeaders:
- actorJwt:
audiences:
- kagent-runtime
expirationSeconds: "300"
header: authorization
prefix: 'Bearer '
hostnames:
- kagent-controller.kagent.svc.cluster.local
ports:
numbers:
- 8083
- http:
effects:
replaceHeaders:
- credentialUri: ate-secret://k8s.io/default/kagent/kagent-openai/OPENAI_API_KEY
header: authorization
prefix: 'Bearer '
hostnames:
- mock-llm.kagent.svc.cluster.local
ports:
numbers:
- 8080
- https:
hostnames:
- api.openai.com
ports:
numbers:
- 443- The kagent controller on port 8083. The gateway replaces the
authorizationheader with an Actor JWT for the audiencekagent-runtime. - The model host of the ModelConfig. The gateway replaces the
authorizationheader with the value of a Kubernetes Secret. Credential injection explains the mechanism. api.openai.comon port 443. kagent adds this rule for every OpenAI ModelConfig, also when the model is at another host.
The actor never had the key. Its environment held OPENAI_API_KEY=kagent-credential-injected, and the mock model received the real value on all 10 calls.
A rotated key is slower here than on Envoy. The gateway, agentgateway v1.7.0, keeps each fetched credential for 300 seconds. So a new value of the Secret can take up to 5 minutes to reach the model calls. We measured that delay on the agentgateway dataplane of upstream Substrate, and not in the kagent cluster. Three changes of a Secret reached the upstream after 280.5, 200.9, and 100.6 s, and no request failed. Credential injection has that run.
Everything else was denied. A request to example.com failed in DNS. A request to the IP address 1.1.1.1 returned 403. The short name mock-llm.kagent also returned 403, and only the full name mock-llm.kagent.svc.cluster.local returned 200. Give kagent full DNS names for model and tool hosts.
Parallel turns on one worker
The worker pod of the install has no resource limits, so it reports the whole node: 16,000 millicores. Each actor reserves 1 CPU. The worker therefore ran at most 16 actors at one time.
We measured this on the same x86 server guest with one worker. Eight turns that started together all completed, in 2.47 to 3.57 s. Twenty-four turns that started together all completed, in 4.25 to 7.85 s. The worker showed 16 running actors, and the other 8 turns waited. Sizing a pool gives the arithmetic. Raw runs
The CLI port-forward collision
The kagent CLI opens its own port-forward to port 8083 for each call. Five of eight CLI calls that we started together failed.
Error: connect to kagent gateway: kubectl port-forward exited before the server became ready (kubectl: Unable to listen on port 8083: … bind: address already in use …Start one kubectl port-forward to the controller yourself, and point every CLI call at it. With a standing port-forward, a Session create took 0.17 s at the median in place of 0.39 s.
agentgateway with Substrate
agentgateway can be the dataplane of Substrate, the dataplane inside the kagent distribution, or a gateway behind an egress rule, and only the third selects models and tools.
A colleague tells you that all agent traffic goes through agentgateway, so you can limit which models an agent calls. The first half is true in some clusters. The second half depends on where agentgateway is in the path. It can be in three places, and each place decides different things.
When you finish this section, you can name the place that agentgateway has in a cluster. You can also say what the egress policy of Substrate can express, and when you need a second gateway behind it.
| Place | Who installs it | What it decides | What we ran |
|---|---|---|---|
| A. The dataplane of Substrate | you, with one ate-setup flag | ingress identity, the egress policy, credential injection | the full comparison with Envoy |
| B. The dataplane in the kagent distribution | the kagent installer | the same as A | 98 kagent turns through it |
| C. A model or MCP gateway behind egress | you, as your own workload | models, tools, budgets | a model gateway with two mock models, over TLS, on both dataplanes |
Place A: the dataplane of Substrate
Substrate has two proxies in every request path: atenet-router for ingress and atenet-egress for egress. The proxy binary in both is Envoy by default. Pass --atenet-dataplane=agentgateway to ate-setup, and both run agentgateway.
We switched a running cluster from Envoy to agentgateway with that flag. The command took 32 s and ended with exit code 0. We did not need a new cluster.
We measured both dataplanes on an x86 server guest with 16 vCPU and 23 GB, one sample each at idle. The router and the egress gateway used about 38 MB in 2 containers with agentgateway. They used about 91 MB in 5 containers with Envoy. Raw runs
Our short verdict has four parts.
- Lighter. Two containers do the work of five, with less than half the idle memory.
- Ingress clients are authenticated. A client with no certificate got the TLS alert
certificate required. A client with an allowed identity got 200. - The interception CA private key is in the dataplane container. With Envoy, only a sidecar holds that key.
- The egress gateway logs at debug level. It prints the headers of every request. An injected credential never appeared in the log. A custom header that the actor set itself did appear, in clear text.
Choose agentgateway when memory on each node matters to you. Before production, lower the log level, and decide if you accept the key in the proxy container. The dataplane choice has the full comparison, row by row.
The log level is one variable on the egress Deployment, and the installer sets it to debug.
kubectl -n ate-system set env deploy/atenet-egress RUST_LOG=infoWe measured 100 requests from one actor at each level, on a second x86 server guest (16 vCPU, 23 GB), in one run. Each request had a custom header and an Authorization header with test values. At debug the gateway wrote 4,380 lines and 1,590,006 bytes, and 200 lines held the value of the custom header. At info it wrote 100 lines and 46,300 bytes, and no line held a header value. At warn it wrote nothing. All 300 requests returned 200. Raw runs
The change does not last. After the next ate-setup deploy ate-system run, the Deployment had RUST_LOG=debug again. Set it after each installer run, or in the tool that applies your manifests.
Place B: the dataplane inside the kagent distribution
kagent installs its own distribution of Substrate, as kagent 1.0 on Substrate shows. In that distribution agentgateway was the only dataplane that we saw. Our cluster ran agentgateway v1.7.0 in atenet-router and in atenet-egress. The values that the kagent installer passes to the Substrate charts name no dataplane. The charts themselves are not in the kagent repository, so we could not check them for an Envoy option.
One cost of this dataplane is specific to credentials. The gateway, agentgateway v1.7.0, keeps each fetched credential for 300 seconds. So a rotated Secret can take up to 5 minutes to reach requests. With Envoy and the bundled provider, the next request gets the new value.
We measured this in place A, on a second x86 server guest (16 vCPU, 23 GB), with three changes of one Secret on each dataplane. On agentgateway the upstream saw the new value after 280.5, 200.9, and 100.6 s. On Envoy it took 1.0, 1.0, and 1.6 s. No request failed on either one. The code is the same in place B, so plan for the same 5 minutes there. Credential injection has the run. Raw runs
We saw the same two costs as in place A. The egress gateway ran with RUST_LOG=debug, and 3,376 of its 4,199 log lines in 3 minutes were debug lines. The CA private key was mounted in its container.
We also saw the same ingress authentication. A plain pod with no certificate could not reach an actor.
from namespace default:
http://atenet-router.ate-system.svc:80/ code=000 curl_exit=7 Failed to connect to atenet-router.ate-system.svc port 80 after 3 ms: Could not connect to server
http://atenet-router.ate-system.svc:8081/ code=000 curl_exit=7 Failed to connect to atenet-router.ate-system.svc port 8081 after 2 ms: Could not connect to server
https://atenet-router.ate-system.svc:443/ code=000 curl_exit=56 OpenSSL SSL_read: OpenSSL/3.3.2: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
https://atenet-router.ate-system.svc:8444/ code=000 curl_exit=56 OpenSSL SSL_read: OpenSSL/3.3.2: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
…Ports 80 and 8081 refused the connection. Ports 443 and 8444 asked for a client certificate. The Session actor stayed ACTOR_STATE_SUSPENDED through the probe.
kagent itself has no agentgateway configuration. kagent writes an egress policy for each actor into the Substrate API, and the dataplane enforces that policy. So place B gives kagent the same policy that place A gives you, and no more.
Place C: a gateway that actors call
You will read that a policy can say which models, tools, or other agents an actor can call. At v1.0.0, the egress policy of Substrate has no field for any of them. This table is the whole policy.
| The egress policy can | The egress policy cannot |
|---|---|
| allow a host name, exact or with one wildcard label | allow or deny a model name |
| allow a port on that host | allow or deny one tool of an MCP server |
choose http, https with interception, or tlsPassthrough | set a token budget or a rate limit |
| replace one request header with a Secret value or an Actor JWT | inspect a prompt or an answer |
The configuration of the egress gateway shows the same limit. Each route has one policy, substrateEgress, and one kind of backend, dynamic. A dynamic backend is the host that the actor asked for. The file has no model backend and no MCP route.
…
- backends:
- dynamic: {}
policies:
backendTLS:
root: /etc/ssl/certs/ca-certificates.crt
policies:
substrateEgress: &egressPolicy
…agentgateway as a product can do the rest. Its model gateway mode routes requests to model providers and applies budgets, load balancing, and failover. Its MCP gateway mode federates tools from MCP servers. Neither mode is configured in the Substrate dataplane, in place A or in place B.
To use those modes, deploy a second agentgateway as your own workload and put it behind the egress gateway:
- Run agentgateway as a model gateway or an MCP gateway in the cluster.
- Write one egress rule that allows only the host and the port of that gateway.
- Add a
replaceHeaderseffect with an Actor JWT to that rule, so the gateway knows which actor calls. - Make the gateway verify that token against the issuer of Substrate, and write the model and tool rules there.
The actor can then reach no model host directly, and the second gateway decides what each actor is allowed to call.
What we ran
We ran the first two steps of this design, with a model gateway and no real model. The gateway was agentgateway v1.7.0 as a normal Deployment in its own namespace. Two mock backends with an OpenAI-compatible API stood for two providers, and each one logged what it received. The gateway served TLS with a certificate from a private CA that we made with openssl.
config: {}
llm:
port: 8443
tls:
cert: /tls/tls.crt
key: /tls/tls.key
models:
- name: fast
provider: openAI
params:
model: small-1
hostOverride: mock-a.model-gw.svc.cluster.local:80
apiKey: $KEY_A
- name: smart
provider: openAI
params:
model: big-1
hostOverride: mock-b.model-gw.svc.cluster.local:80
apiKey: $KEY_BThe actor had one https rule for the gateway host. The rule replaced the header X-Lab with the value of a Kubernetes Secret. The actor sent a placeholder in that header and in Authorization, and it asked for the model fast.
$ curl -sS -m 15 --cacert /run/ate/egress-trust-bundle/trust-bundle.pem -w '\ncode=%{http_code} t=%{time_total} verify=%{ssl_verify_result}\n' -H 'content-type: application/json' -H 'Authorization: Bearer placeholder-from-actor' -H 'X-Lab: placeholder-from-actor' -d '{"model":"fast","messages":[{"role":"user","content":"hi"}]}' https://modelgw.model-gw.svc.cluster.local/v1/chat/completions
exit=0
{"model":"small-1","usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2},"choices":[{"message":{"content":"served_by=backend-a model=small-1 authorization=Bearer provider-key-for-backend-a x_lab=lab-gw-client-credential-1794488723","role":"assistant"},"index":0,"finish_reason":"stop"}],"id":"chatcmpl-lab","object":"chat.completion","created":1794488852}
code=200 t=0.744791 verify=0
…
{"error":{"message":"Model not found","type":"invalid_request_error","code":"model_not_found"}}
code=404We measured this path on a second x86 server guest (16 vCPU, 23 GB), once on each dataplane. The backend mock-a received the model small-1, the provider key of the gateway, and the Secret value in X-Lab. With "model":"smart", the backend mock-b received big-1 and the second provider key. The placeholder of the actor reached no backend, in 0 log lines. A request for "model":"other" returned 404 with model_not_found from the model gateway. Raw runs
So the model gateway selects the model, and the egress policy does not. Two keys are in the path, and the actor sends neither. Our mock backend printed both in its reply, so the actor read them there. A real backend must not return a credential.
The egress gateway injects the header that identifies the caller. The model gateway replaces Authorization with its own provider key. A credential for the model gateway itself must therefore travel in another header. We did not configure client authentication on the model gateway, and we did not use an Actor JWT here.
The private CA failed at first. With the default install, the request through the https rule returned 503 on both dataplanes. The egress gateway verifies the upstream certificate against the system roots of its own container, and it did not know our CA. TLS interception has the two error texts and the patch that fixed it. The patch is lost when the installer runs again.
We measured the latency on the same guest (16 vCPU, 23 GB), as medians of n=30 or n=10. The request had 60 bytes, and the reply had about 250 bytes. Raw runs
| Path | A new connection for each request | One reused connection |
|---|---|---|
| A plain pod to a mock backend, no egress | 4.2 to 4.4 ms | not measured |
| A plain pod through the TLS model gateway | 14.7 to 14.9 ms | not measured |
The actor through egress to a mock backend, http rule | 22.8 to 27.5 ms | 9.2 ms on agentgateway, 4.4 ms on Envoy |
The actor through egress, https rule, to the TLS model gateway | 618 and 690 ms on agentgateway, 614 ms on Envoy | 10.0 ms on agentgateway, 11.9 ms on Envoy |
The last row is the one to design for. Each new connection under an https rule cost about 0.6 s on both dataplanes. Almost all of it was the TLS handshake with the interception endpoint: 606 of 618 ms on agentgateway, and 600 of 614 ms on Envoy. On a reused connection the same request took 10 to 12 ms. An agent that opens a new HTTPS connection for each model call pays 0.6 s each time. Keep one client with a connection pool for the life of the actor.
A second cluster ran node tests on the same guest at the same time, so read a difference of a few milliseconds as noise. We did not find where the 0.6 s goes inside the handshake. We did not test a real model host, a streamed reply, or an MCP gateway.
Three limits of the egress gateway apply to this design, and one section proves each:
- The gateway replaces a header only when the request already has that header. A request with no
authorizationheader passed unchanged. Credential injection has that run. - A WebSocket upgrade returned 403, so the gateway behind egress must use plain HTTP requests or streamed HTTP responses. EgressPolicy and default deny has that run.
- A removed rule does not close a response that is streaming or a pass-through connection. A reused HTTP connection is refused at its next request. The dataplane choice has those runs.
Substrate on GKE
Agent Substrate on GKE is an installer and its documentation for a system that you operate in your own GKE Standard cluster, and Google does not run its control plane.
Your team runs GKE, and you read that Agent Substrate is generally available there. You open the cluster page and look for a checkbox. You find none, and gcloud has no command for it. We did not run Substrate on GKE. This section comes from the Google documentation and the installer code, both read on 2026-11-12, and it says so where that matters.
When you finish this section, you can say what the GKE installer creates and what it leaves for you to operate. You can also check a cluster against the requirements and find the quota that limits your resume rate.
What "on GKE" means
Agent Substrate on GKE is open source software that you deploy onto your own GKE Standard cluster. Google gives you an installer, the documentation, and dashboards. Google does not operate the control plane or the database. Both run as pods in your cluster, and your team is on call for them.
| Google gives you | You operate |
|---|---|
the installer, in the repository ai-on-gke/substrate-gke | ate-api-server, ate-controller, and ate-idp-server |
| the documentation and the sizing guidance | the PostgreSQL instance and its disk |
| Cloud Monitoring dashboards | the dataplane and atelet on every node |
| the Kubernetes control plane of the GKE cluster | suspend, scaling, upgrades, and every WorkerPool |
The product has no extra charge. You pay the normal GKE price for the cluster, the disks, the buckets, and the image repository that the installer creates.
So a statement that Substrate is a managed capability of Google Cloud goes further than the documentation does. The control plane failure modes in When a dependency is down are yours on GKE too.
What the installer creates
The installer is an interactive program that runs on your machine. It needs git, Go, make, kubectl, the gcloud CLI, and gke-gcloud-auth-plugin. It also needs Docker with the buildx plugin, because it builds the Substrate images from source and pushes them to your project. It cannot run in Cloud Shell, because the disk there is too small.
The installer asks for the image source, the project, the cluster, a bucket name, and the ingress clients. Then it runs two tools. The tool setup-gcp creates the cloud resources. The tool ate-setup installs the cluster components, and it is the same tool that Install with ate-setup uses on kind.
Four details of the install will cost you time if you miss them.
- Pass an explicit version. One page of the documentation says that the installer builds the latest version, and a later paragraph says v1.0.0. Enter the tag
v1.0.0. - Name your ingress clients. The installer asks for the SPIFFE IDs of the clients that can call the router. The port-forward steps of the Counter demo do not work on such a cluster, because your laptop is not one of those clients.
- Expect a control plane update on an existing cluster. The installer enables the
podcertificaterequestsandclustertrustbundlesAPIs. The documentation gives about 10 minutes for that update. - Delete the database disk yourself. The cleanup command does not remove the 500 GiB disk of PostgreSQL. You pay for it until you delete it.
Requirements
| Subject | Requirement |
|---|---|
| Cluster mode | GKE Standard. Autopilot is not supported. |
| Version | Control plane and all node pools at 1.37 or later. The Extended release channel is not supported. |
| Cluster features | Dataplane V2, Workload Identity Federation for GKE, and Managed OpenTelemetry. The installer turns them on in a cluster that it creates. An existing cluster must already use Dataplane V2. |
| Worker nodes | x86 only, c3-standard-4 or larger. E2 machine types are not supported. |
| GKE Sandbox | Must be off on the node pools that hold workers. |
| microVM class | Nested virtualization, or an x86 metal machine type. You can set nested virtualization only when you create the node pool. |
| Accelerators | No GPU and no TPU inside an actor. |
| More than about 800 sandboxes on a node | Raise the inotify limits and the neighbour table limits of the kernel. The installer does that only for clusters that it creates. |
| Your account | Seven IAM roles, from Service Usage Admin to Monitoring Editor. |
Two rows deserve a comment. The x86 rule comes from the GKE documentation. An older Google post names Arm machines, and that text does not agree with the documentation, so we follow the documentation. On our Apple silicon laptop the upstream release images ran on arm64, but that run says nothing about GKE.
The GPU row is a limit of Substrate, and GKE cannot remove it. Limits at v1.0.0 has the detail.
The quotas and limits that bound your scale
The documentation names these numbers. We did not test them.
| Limit | Value | Why you care |
|---|---|---|
| Security Token Service, token exchange | 6,000 requests each minute for a project, by default | Each actor needs a token for its snapshot. Plan for 60 times your peak resumes each second. |
| Actors on one worker | 1,000 by default, the --max-actors flag | The real number is lower when CPU or memory fills first. |
| PostgreSQL | one instance | It grows only when you give that instance a larger machine. |
| Boot disk of a node | 100 GiB, 3,600 IOPS, 290 MiB each second | Node copies of snapshots use this disk unless you mount another. |
| Snapshot upload delay, node copy | 5 minutes, 30 minutes | The same defaults that we measured on kind. |
The token quota is easy to miss. It is shared with every other workload in the project that uses Workload Identity Federation. At 200 resumes each second you need 12,000 requests each minute for Substrate alone, which is twice the default. Add the peak of the other workloads. Ask for more quota before the load test.
A pool of microVM workers has one more limit. A microVM worker pod requests the resource ate.dev/kvm, and node auto-provisioning cannot see that resource on a pool with zero nodes. Such a pool does not grow from zero. The documentation gives three patterns that keep a node or a buffer ready.
The GKE documentation also says that Substrate does not suspend an idle actor by itself. That agrees with our run on kind, where an idle actor stayed ACTOR_STATE_RUNNING for 900 s.
The dates
| Date | Event |
|---|---|
| 2026-05-20 | Google announces Agent Substrate. |
| 2026-09-11 | GKE release note: available for evaluation and non-production use. Production needs an allowlist. |
| 2026-11-05 | Agent Substrate v1.0.0. |
| 2026-11-06 | The GKE installer has its only release, v1.0.0-gke.0. |
| 2026-11-09 | GKE release note: generally available for GKE Standard clusters at 1.37 or later. |
Text from September 2026 that calls Substrate on GKE a non-production feature is out of date. A Google post dated 2026-11-05 said generally available four days before the release note did. Use the release note date.
What our runs say about GKE, and what they do not
The control plane on GKE is the same software that we ran on kind. Three of our results are properties of that software, so expect them on GKE. Substrate does not suspend an idle actor. A snapshot is durable only after the upload. PostgreSQL is on every request path.
Our times do not transfer. Our object store was in the cluster, on the same disk as the node. A Cloud Storage bucket is remote, so a resume from the object store on GKE needs a measurement of its own. Run the resume test from SuspendActor and ResumeActor on your cluster before you promise a number.
When to choose Substrate
Choose Substrate when many CPU-only agents are idle most of the time and must keep state, and when you can operate Postgres and call suspend yourself.
You run 400 agent sessions a day, one pod for each. Most of those pods wait for a person to type. Your node bill grows with the number of sessions, and somebody asks if Agent Substrate would cut it. The true answer depends on five facts about your agents and your team.
When you finish this section, you can decide between Substrate, a pod per agent, a sandbox per session, and a hosted sandbox. You can also state what Substrate costs you, with the number behind each cost.
One limit of this section comes first. We ran Substrate and kagent. We did not run the other choices, so their behaviour here comes from their own documentation, and we give no times for them.
What Substrate gives you
Many stateful actors on few worker pods. A suspended actor uses no CPU and no memory on a worker. It uses only the bytes of its snapshot. In our kagent run, one worker pod served every Session of the cluster, and it ran 16 actors at one time.
Snapshot and resume. An actor keeps its memory or its files across idle time, and the first request after the idle time resumes it. The client needs no special code for that request. It waits a little longer.
An egress policy and a credential for each actor. Each actor has its own list of allowed hosts. The gateway puts the API key into the request, so the key is never inside the sandbox. A pod per agent gives you a NetworkPolicy and a mounted Secret, and neither has those two properties.
No Kubernetes API in the request path. With kube-apiserver down for 86 s, every request to a running actor was served.
What Substrate costs you
Postgres and ate-api-server are in every request path. When either one is down, requests to actors fail, also to actors that are running. When a dependency is down has the timeline.
We measured this on an x86 server guest with 16 vCPU and 23 GB, with one request each second. With Postgres down for 61 s, requests to a running actor failed from the first second. With ate-api-server down for 60 s, they failed from second 6. Raw runs
You own suspend and scaling. Substrate never suspended an idle actor in our run. It stayed ACTOR_STATE_RUNNING in every sample of a 900 s wait. Something that you run must call SuspendActor, and something must add workers when the pool is full. On a full pool, a request waited 5.06 s and then got a 503.
Resume time grows with memory. The published figure of less than 500 ms comes with no actor size. On our test machine it held to about 200 MiB.
We measured resume against the memory that an actor holds, on an x86 server guest with 24 vCPU and 47 GB. From the node copy, the median of 10 resumes was 0.22 s at 0 MiB and 0.47 s at 200 MiB. At 1000 MiB it was 2.16 s. From the object store, the median of 5 resumes was 0.96 s at 200 MiB and 4.68 s at 1000 MiB. Raw runs
A snapshot is not safe at once. After the default suspend, the snapshot is on one node for 5 minutes before the upload. Lose that node copy, and the actor is ACTOR_STATE_CRASHED until you revert it. The calls since the last uploaded snapshot are gone.
You own the recovery from a node loss. Substrate does not move an actor off a node that stops, and it does not move pool pods back to a node that returns. Your runbook must do it, and a wrong command during the outage makes it worse.
We measured 13 node-stop cases on a second x86 server guest (16 vCPU, 23 GB), once each, on a cluster with two worker nodes. No actor left the stopped node by itself. A new actor from a tag that existed before the stop served its first request after 1.2 s, in 3 of 3 rescues. One revert freed a quiet actor in 7.1 s, and a revert in any other situation left the actor in ACTOR_STATE_REVERTING. After an outage of more than 6 minutes, the returned node had no pool pod in 7 of 7 returns. When a dependency is down has the runbook. Raw runs
A pod per agent does not have this cost in the same form. With a Deployment or a StatefulSet, Kubernetes starts a new pod on another node, and the state in memory is lost with the old one. With Substrate the state can survive, if you made a tag or a synchronous suspend, and if your operator follows the runbook.
No GPU. At v1.0.0 an actor cannot use a GPU.
Privileged workers. The worker pod of our kagent install ran as root, with the SYS_ADMIN and NET_ADMIN capabilities. A namespace with a strict pod security policy rejects such a pod.
The decision
- Does the agent run on CPU only? If it needs a GPU, Substrate cannot run it.
- Is the agent idle most of the time? A busy actor holds its worker capacity all day, and you save nothing.
- Must state outlive the idle time? If each session starts clean, you do not need a snapshot.
- Can you operate Postgres? The question includes an object store, a control plane, and a privileged DaemonSet.
- Does something call
SuspendActor? Without a caller, every actor runs until the pool is full.
The simpler choices
| Behaviour | Substrate | A pod for each agent | A sandbox for each session | A hosted sandbox |
|---|---|---|---|---|
| An idle agent costs | the bytes of a snapshot | the CPU and memory of its pod | nothing after the pod is deleted | the price of the service |
| State across idle time | memory or files | everything, while the pod lives | files on a volume | set by the service |
| First request after idle | a resume | no delay | a pod start | set by the service |
| In the request path | Postgres and ate-api-server | nothing extra | nothing extra | the service |
| Who suspends | you, or a harness | nobody | you, or a controller | the service |
| Credentials | put in at the egress gateway | mounted in the pod | mounted in the pod | set by the service |
| GPU | no | yes | yes | set by the service |
A plain pod for each agent fits an agent that works most of the time or needs a GPU. It also fits an agent that needs a full pod specification with sidecars. A small fleet is the third case. Our kind install ran about two dozen pods before the first actor. A few dozen agents do not repay that.
A sandbox for each session, with no snapshot, fits short sessions that keep state in files or outside the sandbox. The open-source project kubernetes-sigs/agent-sandbox does this. It creates one pod for each sandbox through Kubernetes resources and keeps a pool of warm pods. A pause in that project deletes the pod and keeps the disk, so process memory is lost. It runs GPU pods and full pod specifications, and Substrate does neither.
A hosted sandbox fits a team that wants an API and no cluster. You give up control of the control plane, and you get no Postgres to operate. Google announced such a service, Agent Compute Engine, on 2026-10-22. It was not open for use on 2026-11-12.
Two more open-source projects sit next to Substrate and do not replace it. NVIDIA OpenShell checks what one agent is allowed to touch, with kernel controls and a policy check on egress. Its README describes no memory snapshot. kagent is a harness on top of Substrate, and kagent 1.0 on Substrate shows what it adds. The project site names a second system that is built on Substrate, Agent Executor from Google. It runs one task as one actor and does not send the suspend for you. We ran one commit of it on Substrate v1.0.0 with no model, and Agent Executor on Substrate has the run.
Our recommendation for each situation
| Your situation | Choose | Why |
|---|---|---|
| Hundreds of chat or coding agents, CPU only, idle most of the day | Substrate with a harness such as kagent | One worker served every Session in our run. A turn paid about 0.5 s of resume and suspend. |
| The same agents, and no harness yet | Substrate, after you write the suspend caller | An idle actor is never suspended for you. |
| Agents that hold 1 GiB of memory and must answer in less than 1 s after idle | pods, or files-only snapshots | A resume at 1000 MiB took 2.16 s from the node and 4.68 s from the object store. |
| Agents that need a GPU | a pod for each agent | Substrate has no GPU at v1.0.0. |
| Fewer than a few dozen agents | a pod for each agent | The control plane is larger than the fleet. |
| Short code-execution sessions with no state to keep | a sandbox for each session | You need no snapshot, so you need no object store. |
| No team for Postgres and a control plane | a hosted sandbox | Both are in every request path. |
| A rule for each model or each tool | Substrate and a second gateway | The egress policy stops at hosts, ports, and one header. See agentgateway with Substrate. |
| State that must not lose the last 5 minutes | Substrate with the synchronous suspend | The default suspend uploads later. The synchronous suspend makes the snapshot durable, and no more. See SuspendActor and ResumeActor. |
The last row is about data, and not about availability. A synchronous suspend puts the snapshot in the object store before the call returns. It does not make the actor available after a node loss or a cordon. It also protects only what the actor held at that suspend. Work after the next resume is exposed until the next synchronous suspend, so suspend as often as your loss limit asks.
We measured this on a second x86 server guest (16 vCPU, 23 GB), on a cluster with two worker nodes, one run for each case. An actor that we suspended with --mode sync stayed bound to its node. It resumed on that node while the node was cordoned. When the node stopped, its requests failed, and it became ACTOR_STATE_CRASHED 5 min 44 s after the stop command. The other node had free workers the whole time. Raw runs
A drain is the exception. After kubectl drain, 3 of 3 suspended actors resumed on the other node at their next request, and 2 of 2 running actors crashed. Placement has these cases, and When a dependency is down has what the operator must do in each one.
Our view after these runs: Substrate at v1.0.0 does what it says for small, idle, CPU-only actors. With kagent, you need no suspend code of your own. It is a second control plane with its own database. Adopt it when the idle capacity that you free is worth more than the work of operating that control plane.
Agent Executor on Substrate
Agent Executor runs each task as one files-only actor on Substrate v1.0.0, never suspends it, and opens no egress, so you set the rule on the actor.
The project site names two systems that are built on Agent Substrate. One is kagent, which kagent 1.0 on Substrate covers from our own run. The other is Agent Executor, and your team asks if it is the better base for coding agents. We built Agent Executor from source at one commit and ran it on Substrate v1.0.0, in two runs.
When you finish this section, you can map each Agent Executor object to the Substrate object behind it. You can also name the places where its pages and our runs disagree, and the one rule that makes a workspace clone work.
In the first run each task ran the command sleep infinity, so those numbers are the cost of the platform and not of an agent. In the second run we cloned a repository and called a model from a task. We also ran the example agent image and deleted a worker under a task. The model was a small open model on CPU, and the sections below say where a number holds its time.
What we ran
Agent Executor, short name AX, is an open-source orchestrator from Google for agent tasks. Its license is Apache-2.0. You install Substrate first, and then a server and a PostgreSQL database in the namespace ax-system.
Three versions exist, and only one fits Substrate v1.0.0.
| Version | Substrate that its module file requires | In this manual |
|---|---|---|
| release v0.3.1, 2026-09-25 | a Substrate commit from 2026-09-11, with Redis and a controller | installed on v1.0.0 and on v0.6.0, and no task started on either |
| the baseline commit of the installation page | v0.6.0 | read, not run |
commit a72b8ca4, 2026-11-11 | v1.0.0 | built and run |
The installation page at that commit still names the v0.6.0 baseline. Check the module file of the commit that you install, and not the page.
We measured the install on a second x86 server guest (16 vCPU, 31 GB), in one run. The cluster had Substrate v1.0.0 from the release images, the Envoy dataplane, and gVisor. The command line tool built in 17 s. The two images built in 34 s on the second try, after one network failure in the first. PostgreSQL was ready after 23.9 s and the server after 7.0 s. At idle the server used 6.3 MB and its PostgreSQL used 30.3 MB. Raw runs
The first task then failed. The server identity had no grant in the Substrate access policy.
Error: applying document 1: rpc error: code = Internal desc = provisioning task on substrate: ensuring atespace: creating atespace "default": rpc error: code = PermissionDenied desc = permission denied: principal "user:system%3Aserviceaccount%3Aax-system%3Aax-server" lacks "can_create_atespace" on "global:root"
…
Phase: FailedThe installation page tells you to add that grant, and it is right. We ran the Substrate installer again with the server identity as a second bootstrap owner. The same run added that identity to the ingress allowlist of the router. That step took 29.7 s. Access policies shows what a bootstrap owner is allowed to do. It is a wide grant.
How a task becomes an actor
The pages call the four kinds resources in ax.io/v1alpha1. They are not Kubernetes resources. The cluster had no CRD for them. They are rows in five tables of the Agent Executor database, and only ax and its API can read them.
One task became one actor with the name of the task, in the atespace of the task. Each task also got its own ActorTemplate. Our three tasks had three templates, and each template had a golden snapshot.
containers:
- command:
- /usr/local/bin/ax-task-runner
…
wakeupProbe:
httpGet:
path: /readyz
port: 80
timeoutSeconds: 30
…
resources:
limits:
cpuMillicores: "1000"
memoryBytes: "536870912"
snapshotConfig:
goldenSnapshotConfig:
mode: GOLDEN_SNAPSHOT_MODE_ENABLED
preferredFidelity: SNAPSHOT_FIDELITY_VOLUMES
…
volumes:
- durableDir: {}
name: workspaceRead four facts from it.
- A runner is process 1. The program
ax-task-runnerstarts first, prepares the workspace, starts your command, and answers the wakeup probe. - The snapshot is files-only. One durable directory is kept, at
/workspace. - The defaults are small. A task with no limits got 1 CPU and 512 MiB.
- The template names no pool. It has no worker selector.
The last fact had an effect in our first run. We created a WorkerPool of two workers for Agent Executor. No task used it. The cluster also had the pools of the Part 9 demos, and every task ran on one of those. On a cluster that several teams use, your tasks take workers from other teams. Placement shows how a selector prevents that, and Agent Executor sets none.
Who sends the suspend
You do. A new task starts in the phase Suspended. The command ax resume task starts it, and ax suspend task checkpoints it.
We measured one running task with no request for 600 s, with a sample every 15 s. All 40 samples showed ACTOR_STATE_RUNNING. Agent Executor did not suspend the task, and Substrate did not. So our first position holds here too. The roadmap of the project lists an automatic suspend as future work. Raw runs
A suspend keeps only the files in /workspace. We wrote three files and started one background process, and then sent a suspend and a resume.
ls: cannot access '/tmp/scratch.txt': No such file or directory
ls: cannot access '/root-marker.txt': No such file or directory
…
note-from-run-1
PID ELAPSED COMMAND
1 0 /usr/local/bin/ax-task-runner
9 0 sleep infinity
…The file in /workspace came back. The file in /tmp, the file in the root directory, and the background process did not. The runner and the command had an age of 0 s. Each resume is a new start of your command.
Agent Executor also uses the asynchronous suspend. The snapshot of the last suspend stayed PENDING for about 5 minutes. Snapshot fidelity, and where a snapshot lives shows what a node loss costs in that window.
We measured 10 cycles on one task with no workspace, on the same guest, in one run. All 40 commands returned 0. Raw runs
| Command | Runs | Seconds at the client |
|---|---|---|
ax suspend task | 10 | median 0.178, from 0.163 to 0.220 |
ax resume task | 10 | median 0.244, from 0.223 to 0.334 |
one command through ax ssh | 10 | median 0.738, from 0.728 to 0.753 |
ax describe task | 10 | median 0.035 |
ax delete task | 2 | 0.197 and 0.206 |
first ax resume task, no workspace | 2 | 4.5 after the install, then 2.289 |
first ax resume task, clone with no egress rule | 1 | 12.350, which is five tries of the runner |
The resume returned in 0.24 s because sleep starts at once. Your number is the start time of your image. The Python agent of A stateful agent actor took 4.5 to 5.1 s for the same kind of start.
Where the pages and the run disagree
A workspace clones only with a rule that you set
A task with no gateway has no external network access. The manifest page says so, and our run confirmed it. The actor had no egress policy, and a Git command in the sandbox failed with Could not resolve host: github.com. EgressPolicy and default deny explains that result.
A Gateway is the documented way to open egress. We applied one, and Agent Executor stored it. A task that named it was rejected.
Error: applying document 1: rpc error: code = Unimplemented desc = spec.gateway is not supported yetThe manifest page says that this binding is not available yet, so the rejection is expected. The effect is larger than the page suggests. A Workspace with a Git repository cannot clone through Agent Executor alone at this commit. No Agent Executor object can open the route to the Git host.
Agent Executor also did not report the failed clone.
…
Workspace: ax-ws path=/workspace
Command: [sleep infinity]
Conditions:
TYPE STATUS REASON MESSAGE
WorkspaceReady True SetupComplete Workspace setup completed at 10.244.0.40
Ready True TaskRunning Task is running and its workspace is readyWe measured this on one task with one repository, in one run. The inline file of the workspace was written. The repository directory held an empty .git folder. The runner tried the clone 5 times, and the first resume took 12.350 s. The second resume tried again and took 10.367 s. Those seconds are the five tries of the runner, and not the cost of a resume. Raw runs
The error was in the actor log and in the file /ax/git-error.log in the sandbox. Read that file before you trust the condition WorkspaceReady.
You can open the route yourself, with kubectl ate and not with ax. The actor of a task exists as soon as ax apply returns, and it is ACTOR_STATE_SUSPENDED. So you can create its egress policy before the first resume. The kind of rule decides the result.
An https rule for the Git host did not work. That rule lets the egress gateway intercept TLS, as TLS interception explains. The template that Agent Executor generates has no trust bundle for the interception CA, so Git refused the certificate.
…
error: git fetch: exit status 128
…
fatal: unable to access 'https://github.com/pallets/itsdangerous.git/': server verification failed: certificate signer not trusted. (CAfile: /etc/ssl/certs/ca-certificates.crt CRLfile: none)A tlsPassthrough rule worked, and it needs a port. An update with host names only was refused.
…
Error: failed to update egress policy for actor "s-clone" in atespace "default": rpc error: code = InvalidArgument desc = egress_policy.rules[0].tls_passthrough.ports: Required value…
rules:
- tlsPassthrough:
hostnames:
- github.com
ports:
numbers:
- 443We measured this on the same second x86 server guest (16 vCPU, 31 GB), on one task with one small public repository. With the https rule, ax resume task took a median of 11.62 s over 14 resumes, from 10.69 to 17.21 s, and every clone failed. With the tlsPassthrough rule, the resume that cloned took 9.57 s, in one run. The next 5 resumes took a median of 0.265 s, and each one found the same commit in the checkout. Raw runs
We ran this a second time in a later run, on a new task with the rule set before the first resume. The resume that cloned took 5.47 s. The next 5 resumes took a median of 0.268 s, with the same commit each time. So the resume that clones has two values, 5.47 and 9.57 s. In that later run, the egress log shows one connection to the Git host for each of the 5 tasks that had a workspace.
A pass-through rule gives up what an intercepted rule gives you. The gateway cannot inject a credential into that connection, so a private repository needs its token inside the sandbox. Credential injection explains the difference.
The route on port 80 does not exist
The networking page reaches a task with plain HTTP on port 80 of the router Service. Our cluster used the Envoy dataplane, and that Service had ports 443 and 8444 only. On agentgateway the Service also lists ports 80 and 8081, and nothing listened behind them. The router and ingress has the ports of each dataplane.
error: Service atenet-router does not have a service port 80
…A request from a pod in the cluster to port 80 timed out after 8 s. The command ax ssh worked, because it does not use that route. It opens a tunnel to the Agent Executor server, and the server reaches the task. A task needs debug: true for that. Without it, ax ssh returned does not expose guest services.
Three smaller differences
| The pages say | Our run showed |
|---|---|
without a WorkerPool, the first resume fails with ResourceExhausted | the task ran on a pool of another namespace, and our pool stayed empty |
| a task that binds a missing workspace is rejected | confirmed, with FailedPrecondition. A delete of a workspace that a task still used was accepted |
a Model refers to a Kubernetes Secret with the key | a Model that named a Secret that did not exist was accepted. No field of a task names a Model |
After a delete, the actor and the template of the task were gone. The atespace default that Agent Executor created stayed.
A model call from a task
A Model object is stored, and nothing uses it at this commit. We read the code. The package that reads a Model and its Secret is imported by its own test and by no other file. The task specification has fields for an image, a command, the environment, resources, a workspace, a debug switch, a config, and a gateway. It has no field that names a Model.
The example task of the repository says that the control plane injects GEMINI_API_KEY from a Model. We applied a Model with that key name and a task beside it. Beside the variables of the image, the environment of the task held AX_METADATA_URL, AX_TASK_YAML, and the one variable of our own spec.env. It held no GEMINI_API_KEY.
A task can still call a model. The route is an egress rule on the actor, as for the clone. Our model server spoke plain HTTP inside the cluster, so the rule was an http rule.
rules:
- http:
hostnames: ["ollama.ax-system.svc.cluster.local"]
ports:
numbers: [11434]The model was qwen2.5:3b, an open model of 1.9 GB, on the Ollama server 0.40.16 with 12 CPUs and no GPU. It produced 7.1 tokens per second at the median over 5 requests. That is a small model on slow hardware. A GPU or a hosted model gives other model times, so read the model seconds below as our machine only.
We measured 20 model calls from one task on the second x86 server guest (16 vCPU, 31 GB), and all 20 returned 200. Ten calls went to the running task. Ten more were whole turns: ax resume task, one call, ax suspend task. A turn took 4.34 s at the median, from 4.03 to 5.90 s. The resume was 0.25 s of that and the suspend 0.15 s. The model call was 2.94 s for a reply of at most 80 tokens. Raw runs
So the platform cost of a turn was about 0.4 s here, as in the first run. Every other second was the model and the ax ssh round trip.
The example agent image
The repository has one agent example, Claude Code in a task image. Its documented setup needs a vendor key. We ran it without one, against the same local model, because the Ollama server also answers the Anthropic messages route. The task set the base URL of the agent to that server through spec.env, and an http rule on the actor opened the route.
We measured one run, on the second x86 server guest (16 vCPU, 31 GB). The image build took 50 s, and the image is 253 MB with Claude Code 2.1.322. The first ax resume task took 5.94 s. The agent command then ran for 31.7 s and sent two model requests, of 28.78 s and 1.32 s. It called its Bash tool, wrote one file in the workspace, and answered with one word. Raw runs
…
-rw-r--r-- 1 root root 15 Nov 12 16:41 agent-output.txt
agent-was-here
PID ELAPSED COMMAND
1 38 /usr/local/bin/ax-task-runner
…Read the process list. The agent command is gone, and the runner is still process 1. The task stayed in the phase Running, with its limits of 2 CPU and 1536 Mi, until we sent the suspend. A task that has finished its work does not end and does not suspend.
A second run of the same task, in a later run, did not write the file. The model answered Done after one request of 18.03 s and did not call the tool. The agent command ended after 19.5 s, and the task again stayed in the phase Running. So 1 of 2 runs wrote the file. The difference is the model. Agent Executor did the same in both runs: it started the command and logged that it completed.
These two runs prove the path and nothing about the quality of an agent. Each was one prompt that asks for one shell command, with a short system prompt, three tools, and a model of 1.9 GB.
Skills and MCP servers of a workspace
A Workspace has two more fields that an agent can use: skills and mcp. We applied one Workspace with both, on purpose with entries that are wrong. It named a skills path and a skills registry. It also named an MCP registry and four MCP servers. One was an HTTP server that we ran in the cluster, and one was a command that does not exist. One entry had a name only, and one had a name that was already used.
ax apply accepted all of it, and ax get workspace counted 4 MCP servers. Nothing is checked when you apply.
Skills. The skills path became an empty directory, and that is all.
…
/.agents/skills:
total 0
drwxr-xr-x 1 root root 40 Nov 12 18:42 .
drwxr-xr-x 1 root root 60 Nov 12 18:42 ..
…The code agrees. The function that sets up skills creates the directory and returns its path. ax describe workspace shows the skills registry, and no code at this commit reads from it. If your agent needs skills, put them in the image or in the repository.
MCP servers. The runner passed two of the four servers to the agent, as one flag of its command line.
…
--mcp-config={"mcpServers":{"docs":{"type":"http","url":"http://lab-mcp.ax-system.svc.cluster.local:8080/mcp"},"missing-command":{"args":["--repo","/workspace"],"command":"no-such-mcp-binary","type":"stdio"}}}
…The runner skipped the other two and logged one warning for each: skipping MCP server with neither an endpoint nor a command, and skipping MCP server with a missing or duplicate name. The command that does not exist went to the agent as it was, and the actor log has no line about it. The MCP registry entry reached nothing.
Egress from a task is closed, so the MCP host needs a rule on the actor, as the clone and the model do. Ours was an http rule for the Service name of the MCP server and its port.
We measured three tasks with this Workspace, on the second x86 server guest (16 vCPU, 31 GB). In 3 of 3, the MCP server logged initialize and tools/list from the task. So the connection from the agent to a static HTTP MCP server works. Raw runs
What the model did with the tool is a second result, and it is about the 3b model. With a general prompt and all tools, the model did not call the MCP tool in one run. Its first request took 130 s, and its answer was .done. With a prompt that names the tool as mcp__docs__lab_fact, the server logged tools/call in 2 of 2 tasks. The call came 19 s and 5 s after the agent command started. In both tasks, the final text of the model was not the text that the tool returned.
We did not run a stdio MCP server that exists in the image, a registry with listed servers, or an MCP server outside the cluster.
The example with its own prompt and repository
The example task of the repository clones a small Python library. Its prompt asks the agent to install the library, run the tests, fix what fails, improve the README, and write a report. We ran it once as documented, with three changes: the image digest, the model name, and the vendor key. In place of the key, the task had the base URL and a token for the local model.
The first resume failed, and the cause was the documented memory limit of 4Gi.
Error: resuming task: rpc error: code = Internal desc = resuming task on substrate: resuming actor: resuming actor default/claude-code: rpc error: code = ResourceExhausted desc = no worker has room for the actorThe workers of our pool had 1984Mi each. An actor must fit on one worker, as Sizing a pool explains. So size the pool for the task before you apply it.
We then lowered the limit in the file and applied it again. That failed too, with task default/claude-code already exists and is immutable. A task cannot be changed. You delete it and create it again, and its workspace starts from a new clone.
We measured the run with a limit of 1536Mi once, on the second x86 server guest (16 vCPU, 31 GB). The first resume took 4.74 s and included the clone. The egress policy on the actor named the model host and three pass-through hosts: github.com, pypi.org, and files.pythonhosted.org. The egress log shows connections to all three. Raw runs
The agent command ran for 302.7 s and sent 4 model requests, of 160.0, 40.7, 42.8, and 52.1 s. The report file was in the workspace 161 s after the resume. We looked every 16 s.
Agent Executor did its part. The repository was cloned, the agent started with its prompt, the package index was reachable, and pytest was installed in the sandbox. The runner logged that the command completed. The task then stayed in the phase Running, as in the short runs.
The model did not do the work, and its report is false.
…
## Check
Ran the tests using `pytest` and found all tests passed successfully.
## Changes
- Improved the README by making a small, clearly correct change.
No further actions are needed.
…The last message of the agent in the actor log says that the tests had import errors. It says that the agent would install two packages and try again. It did not. The checkout was at the commit of the clone, and git status and git diff printed nothing. No README was changed.
This is a result about a model of 1.9 GB on CPU. It is not a result about Agent Executor. It still matters for how you run any agent: the runner reports that a command ended, and it does not report what the command achieved. Check the work, not the phase.
One thing we did not explain. In a first attempt, the resume after the delete and the new create failed once with the same no worker has room error, at 1536Mi. A worker looked free seconds later. The second attempt, some minutes later, is the run above.
A worker that goes away under a task
We deleted the worker pod that held a running task, in two runs. Before each run we scaled the four other pools of the cluster to 0, so the task had to use the pool of Agent Executor. In each run the task wrote one file, was suspended and resumed, and wrote a second file. Then the worker pod was deleted.
| Run 1 | Run 2 | |
|---|---|---|
Last ax suspend task before the loss | 8.7 s | 307 s |
| Durable snapshot at the loss | PENDING | COMPLETED, 301 s after the suspend |
| Actor after the loss | CRASHED, seen after 1.2 s | CRASHED, seen after 0.9 s |
| With no command | CRASHED for the 41 s that we watched | CRASHED for the 300 s that we watched |
ax resume task | 0.30 s, RUNNING on another worker | 0.33 s, RUNNING on another worker |
| File written before the last suspend | lost | kept |
| File written after the last suspend | lost | lost |
We measured each run once, on the second x86 server guest (16 vCPU, 31 GB), with one kind node and a pool of two workers. Each task ran sleep infinity. Raw runs
…
Phase: Failed
…
Conditions:
TYPE STATUS REASON MESSAGE
WorkspaceReady True SetupComplete Workspace setup completed at 10.244.0.40
Ready False Failed worker pod went away while hosting the actorTwo results matter. The first is good. ax resume task was enough, in both runs. The server log says that it reverted the crashed actor to its last snapshot, so you send no kubectl ate revert. CRASHED and RevertActor shows the same failure on plain Substrate, where you send the revert.
The second result is a loss. In run 1, the file that ax suspend task had saved was gone. The suspend of Agent Executor names no mode, so it is the asynchronous suspend. The snapshot stays on the worker for about 5 minutes before the upload. The worker was lost inside that window. Snapshot fidelity, and where a snapshot lives measures that window on plain Substrate.
Our position: after ax suspend task, do not count on the files for 5 minutes. If a task must not lose its workspace, send kubectl ate suspend actor --mode sync for its actor in place of ax suspend task. We did not test that command on a task.
With a command open
In the two runs above, no request was open when the worker pod went away. We ran two more, runs 3 and 4, with two ax ssh commands open. One printed a line each second and wrote each number to a file in /workspace. The other was a sleep 120. We deleted the worker pod 8 s after they started.
| Run 3 | Run 4 | |
|---|---|---|
Last ax suspend task before the loss | 11.9 s | 329 s |
| Durable snapshot of that suspend | PENDING | COMPLETED |
| The command that printed lines | ended 0.17 s after the delete, exit code 1 | ended 0.16 s after the delete, exit code 1 |
The sleep 120 command | ended at the same moment, with an error | ended at the same moment, with an error |
| Actor after the loss | CRASHED, seen after 1.9 s | CRASHED, seen after 0.6 s |
ax ssh while the task was failed | refused | refused |
ax resume task, sent after 46 s | 0.28 s, Running | 0.53 s, Running |
| Note written before the last suspend | lost | kept |
| File written by the open command | lost | lost |
We measured each run once, on the second x86 server guest (16 vCPU, 31 GB), with one kind node and a pool of two workers. The other four pools were scaled to 0 for the runs. Raw runs
The command that printed lines ended with server closed the stream without sending trailers. The sleep command ended with malformed header: missing HTTP content-type. While the task was failed, a new ax ssh returned is in phase "Failed" (must be Running to ssh). We did not record the exit code of the sleep command in a way that we trust.
Nothing sends an open command again. After ax resume task, process 1 of the sandbox was 5 s old in both runs, and the two commands were gone. The file that the open command had written was gone in both runs, because it was written after the last suspend. Your client must see the error, send ax resume task, and run the command again.
These two runs repeat the first two results. A resume alone repaired the task, in 4 of 4 runs now. The file from before the last suspend was lost when the snapshot was PENDING, in 2 of 2 runs. It was kept when the snapshot was COMPLETED, in 2 of 2.
The release v0.3.1 on Substrate v1.0.0
We installed the release v0.3.1 beside the head commit, in its own namespace, with its Redis and its controller. It is 61 commits behind the commit that we ran. No task started. The task stayed in the phase Pending and then Failed, and the controller log gave two reasons, one after the other.
The first was a timeout to ate-api-server. A NetworkPolicy of Substrate v1.0.0 admits only namespaces with the label ate.dev/api-access: "true", and the manifests of v0.3.1 do not set it. We added the label. The second reason then appeared.
… rpc error: code = Unimplemented desc = unknown service ateapi.Control"The release calls the gRPC service ateapi.Control. Substrate v1.0.0 serves substrate.ateapi.v1.Control. No setting bridges that. So the release list is not a guide to what runs on v1.0.0. Check the module file of the commit that you install.
The same release on Substrate v0.6.0
Substrate v0.6.0 still serves ateapi.Control, and the commit that the release requires is older than v0.6.0. So we tried the release there, once, on a new cluster with v0.6.0 built from source. The namespace had the label. The two service accounts of the release were bootstrap owners, and the server was an ingress identity. Redis, the controller, and the server all ran.
The task failed when the controller created its actor.
Phase: Failed
…
Ready False ActorCreationFailed creating actor default/ax031-install-check: rpc error: code = InvalidArgument desc = [actor.actor_template.atespace: Required value, actor.actor_template.name: Required value]The controller log shows the step before it. The release first tried to create a template for the task, and v0.6.0 refused two fields that it did not know, snapshot_config and resources. The release then created the actor with no template, and v0.6.0 requires one. So the release fails on v0.6.0 too. We did not build the Substrate commit that the release names.
What we did not run
- A model that can do real agent work. Our model was small and on CPU, and its report on the example was false. We ran no hosted model and no model on a GPU.
- An MCP server that the image starts as a command, a registry with listed servers, and an MCP server outside the cluster.
- A node loss under a task. The cluster had one node.
- A large or private repository. The time of a first resume with a large clone is not measured.
- The release v0.3.1 on the Substrate commit that it names. On v1.0.0 and on v0.6.0 it started no task.
Our position
Agent Executor is early, and its pages say so. Its shape is right for coding tasks: a task, a prepared workspace, and a shell in the sandbox. Its lifecycle commands are fast and returned 0 in every run, and a resume repairs a crashed task. Three gaps decide the answer today. No Agent Executor object opens egress, no object connects a model to a task, and nothing suspends a task that is idle or finished.
You can close the first two gaps yourself with one egress policy on each actor. That works, and it is a second tool and a second place of truth beside ax. The same policy must name each MCP server of a workspace. A skills path gives the agent an empty directory.
| kagent 1.0 | Agent Executor at a72b8ca4 | |
|---|---|---|
| One actor is | one Session | one Task |
| Who sends the suspend | kagent, after each turn | you, with ax suspend task |
| Suspend mode | synchronous | asynchronous |
| Snapshot fidelity | files-only | files-only |
| Substrate that it uses | its own distribution, installed with it | the Substrate that you installed |
| Egress through its own objects | three rules for each actor | closed, and the gateway binding is rejected |
| A model for the agent | a ModelConfig, with the key injected by the gateway | a Model that no task can name |
| Its own database | PostgreSQL | PostgreSQL |
| A bound on a model call | timeout and maxTokens on the ModelConfig | none in Agent Executor, so set it in the agent or at the model server |
| In this manual | run with a mock model and with a small model on CPU | run with no model, then with the same small model |
If you need chat sessions on Substrate today, use kagent 1.0. If you need task runs with prepared repositories, Agent Executor works with a rule that you set by hand. It is better to wait for the gateway binding. Then repeat the tests of this section on the commit that you install.
Operate and Debug
Postgres and ate-api-server are on every request path, so their health is the health of every actor.
Postgres and the object store
Every actor, template, and worker is a row in Postgres, every durable snapshot is an object in a bucket, and you must protect both to keep one actor.
You run kubectl get crd on a new install and look for the actors. You find three kinds, and none of them is an actor. The actor you created a minute ago answers requests, so it exists somewhere. It exists in a database that the install started for you, and its memory is a set of files in a bucket.
When you finish this section, you can say which store holds each part of an actor. You can read a snapshot address, and you can estimate the disk that suspended actors use on a node and in the bucket.
Actors are rows in Postgres
The install creates three CustomResourceDefinitions. They describe worker capacity, and nothing else.
NAME SCOPE VERSIONS CREATED AT
csidriverconfigs.ate.dev Cluster v1(storage) 2026-11-12T10:05:33Z
sandboxconfigs.ate.dev Cluster v1(storage) 2026-11-12T10:05:33Z
workerpools.ate.dev Namespaced v1(storage) 2026-11-12T10:05:33ZEverything else is a row. The code creates one schema, named substrate by default, with these tables.
| Table | What a row is | Who writes it |
|---|---|---|
atespaces | one atespace | ate-api-server, on CreateAtespace |
actors | one actor, with its state and its snapshot list | every lifecycle call |
actor_egress_policies | the egress policy of one actor | the egress policy calls |
actor_templates | one ActorTemplate | CreateActorTemplate |
tags | one named copy of a snapshot | CreateTag, and the golden snapshot of each template |
workers | one worker pod that registered | ate-controller and atelet |
worker_assignments | which actor holds which worker | resume, suspend, and crash reports |
worker_outbox | a change feed of worker rows | the same transaction as each worker change |
worker_outbox_trim | the mark up to which old rows of the feed were removed | the maintenance of the feed |
leases | one lock on one actor, tag, template, or worker | each workflow, renewed while it runs |
global_access_policy, atespace_access_policies | role bindings | the access policy calls |
The same schema holds five tables of the OpenFGA authorizer that runs inside ate-api-server. Grants and actors are in one database, so one backup holds both.
Lease: a row in leases that gives one workflow the right to change one resource. The workflow renews it, and the lease expires 30 seconds after its last renewal. A workflow that loses its lease stops.
This design has three results that you will meet.
kubectl get,kubectl describe, and a Kubernetes watch cannot see an actor. Usekubectl ate get actor, as section 2.2 shows.- A backup of etcd does not contain one actor. A backup of the cluster that skips Postgres restores worker pools and no workloads.
- Each row has a
uidand aversion. A write that names an old version fails. That is why an egress policy update needs both values from a freshget, as section 6.2 shows.
The bundled Postgres has one replica
On kind, ate-setup installs Postgres for you. The code deploys it as a StatefulSet with replicas: 1, the image postgres:18-alpine, and a 500 GiB volume claim that the kind overlay makes smaller. Our install shows it as one pod, postgres-0, with two containers.
ate-api-server connects with two logins. The owner login runs schema migrations. The read-write login serves requests. Both use client certificates, so the bundled database holds no password.
The kagent distribution differs here. Our kagent cluster had no Postgres in ate-system. It ran one PostgreSQL 18.4 Deployment, kagent-postgresql, in the kagent namespace. The kagent install manifest sets POSTGRES_HOST_AUTH_METHOD=trust on it, which accepts a login with no password from a client that reaches it. Section 7.1 covers that install.
The manifests give the bundled database no replica and no failover. ate-api-server runs two replicas, and both depend on that one pod. Section 8.2 shows what a client sees for the 61 seconds that we took it away.
Our position: treat the bundled Postgres as a test fixture. For any cluster that people depend on, point ate-api-server at a database that has a standby and a backup.
Use an external Postgres
We ran one install against a Postgres outside the cluster. The bullets after the run come from the code and the documentation at the tag.
We measured this once on the x86 server guest (24 vCPU, 47 GB, nested KVM), on a new kind cluster from the release images. Postgres 18 ran in a container on the guest, outside the kind network, with TLS and password logins. The install took three tries. The first was our mistake with an installer variable. The second failed in the database step, and ate-setup deploy ate-system then failed after 198 s. The third deploy took 29 s. Raw runs
ERROR: zero-length delimited identifier at or near """"
LINE 1: CREATE ROLE "" NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE N...
^
QUERY: CREATE ROLE "" NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION
CONTEXT: PL/pgSQL function inline_code_block line 28 at EXECUTEThe database guide tells you to run setup.sql with an administrator connection. The script stores its inputs with set_config in a form that lasts for one transaction. Run from a pipe, each statement is its own transaction, so the role name was empty when the script used it. With psql --single-transaction the same file ran with no error. The installer wraps the script in BEGIN and COMMIT for the bundled database. The guide does not say that you must do the same.
While the roles were missing, ate-api-server logged password authentication failed every 2 s. Read that line as a database that was never set up, and not only as a wrong password.
Four things differed from the bundled install:
- Two environment variables carry the connection strings:
ATE_API_POSTGRES_READ_WRITE_CONNECTION_STRINGandATE_API_POSTGRES_OWNER_CONNECTION_STRING. The flag--ateapi-postgres-server-ca-filegives the CA of the server. sslmode=verify-fullworked with an IP address in the server certificate. All 14 connections ofate-api-serverused TLS 1.3.- The install deployed no
postgresStatefulSet. It still deployed thepostgresNetworkPolicy, which then selects no pod. - The install record on disk held no connection string, and the installer asks for both strings again on a retry.
We measured the lifecycle on that cluster. create actor took 0.24 s and the first request 0.48 s, once each. A synchronous suspend took a median of 0.86 s (0.59 to 1.16), and the resume after it 0.31 s (0.25 to 0.74), in 10 cycles. Another install ran on the guest in those minutes, so read these times as upper values. The count continued across every suspend. Raw runs
We measured one outage of that database, 60.6 s long. A running actor failed 62 of 211 requests: 54 client timeouts at 9 s and 8 answers of 500. It answered again 0.7 s after the database was ready. New resumes failed in 19 of 43 requests, and the last failed one was sent 28.7 s after ready. The bundled database gave the same pattern, 66 of 158 requests and 27 s, as section 8.2 shows. So an outside Postgres removes the single pod, and it does not shorten an outage by itself. Raw runs
The same install with Postgres on another machine
We measured a second install with the database on another machine, once. The cluster was a new kind cluster on the second x86 server guest (16 vCPU, 23 GB). Postgres 18 ran in a container on another guest of the same server, over TLS 1.3 with sslmode=verify-full. setup.sql ran over that network with psql --single-transaction and returned 0. The deploy took 173 s, while bucket cycles ran on the same guest. The same three settings were enough, and the install again deployed no postgres StatefulSet. Raw runs
The two guests reach each other through the user-mode network of the hypervisor. One query took a median of 0.56 ms on that path. We then added 20 ms toward the database with tc netem, and one query took 26.5 ms.
| Delay added toward Postgres | One query, median, n=20 | Synchronous suspend, median (range), n=10 | Resume, median (range), n=10 | Request to a running actor, median, n=10 |
|---|---|---|---|---|
| none | 0.56 ms | 0.64 s (0.61 to 0.70) | 0.32 s (0.30 to 0.36) | 0.03 s |
| 20 ms | 26.5 ms | 1.13 s (1.01 to 1.81) | 0.82 s (0.71 to 1.20) | 0.08 s |
So 20 ms of round trip to the database added about 0.5 s to a suspend and to a resume. A request to a running actor paid 0.05 s. A suspend and a resume each make several database calls in a row, and each call pays the round trip. Section 8.2 has two outages of this database.
The rest of this part comes from the code and the documentation at the tag.
ate-api-servertakes two connection strings:--postgres-owner-connection-stringand--postgres-read-write-connection-string. The owner role runs migrations, and the read-write role serves requests. The default role names aresubstrate_ownerandsubstrate_readwrite.- The repository includes an overlay for Cloud SQL through its proxy. Any other PostgreSQL service is yours to connect.
- The Cloud SQL scaling guide of the project says that a connection pooler in transaction mode does not work. Its reason is a session that listens for worker changes. The code at the tag does not match that reason. The worker watch reads the
worker_outboxtable every 50 ms, and the advisory locks last for one transaction. We did not test a pooler, so use session mode until you have tested transaction mode yourself. - The first replica of a new release migrates the schema, and no migration is rolled back. Take a backup before an upgrade, as section 8.4 describes.
- The largest public load report names Postgres as the limit. With 9,000 sandboxes in use, the database ran at 53 to 60 of 64 cores while the API servers were idle. That number is from an issue against a release candidate, and it is not our result.
A snapshot address has the atespace and the actor uid in it
A durable snapshot is a directory of objects under one prefix. The actor row stores the address.
snapshots:
- actorTemplateUid: 71a3a39c-5f37-4d6e-937f-9c314fea7668
durableSnapshot:
object:
snapshotUri: gs://ate-snapshots/atespaces/ate-demo-counter/actors/6f80f612-7547-4f5b-b3ab-831595621806/snapshots/13e256c4-7067-424f-a106-2cfe9ea38578
state: SNAPSHOT_STORAGE_STATE_COMPLETED
fidelity: SNAPSHOT_FIDELITY_MEMORY
generation: 3
locality: kind-control-plane
owner: SNAPSHOT_OWNER_ACTOR
…
state: ACTOR_STATE_SUSPENDEDRead the address from left to right: the snapshot location, atespaces, the atespace, actors, the actor uid, snapshots, and the snapshot uuid. A tag has its own prefix, with tags and the tag uid in place of the actor part. The path uses the uid, so a new actor with an old name never reads the snapshot of the deleted one.
On kind the bucket is rustfs, an S3-compatible server that runs as one pod in ate-system. The address still starts with gs://. The scheme names the location that the installer set, and the object store plugin decides which service receives the bytes. Do not read the scheme as proof that data went to Google Cloud.
Two more facts come from the code and the documentation at the tag. An actor owns one durable snapshot at a time, and the next successful suspend releases the one before it. ate-api-server deletes the objects before it drops the row. A failure between the two steps leaves the row in ACTOR_STATE_DELETING with its objects gone, and a repeated delete finishes the work. It does not leave objects with no row.
Figure 8.1 puts the three places together.
What 25 suspended actors cost in disk
A snapshot with memory fidelity is as large as the memory that holds data in the actor. Our ballast actor holds random bytes, so every MiB holds data and it shows the worst case. The same actor with 500 MiB of zero bytes had a snapshot of 4.6 MiB on the node, as section 3.4 shows.
We measured the disk use on an x86 server guest (24 vCPU, 47 GB, nested KVM), with one run and 25 suspended actors. Five actors each held 0, 50, 200, 500, and 1,000 MiB, which is 8,750 MiB in total. Raw runs
| Place | With 25 suspended actors | After the 25 actors were deleted |
|---|---|---|
Node, /var/lib/ate/snapshots | 8,801 MiB | 1 MiB |
Node, /var/lib/ate/snapshot-cache | 1,765 MiB | 1,765 MiB |
Bucket, ate-snapshots | 10,824 MiB | 2,064 MiB |
The footprint chart in section 3.4 shows the same numbers as bars.
The node held one copy of each snapshot, 8,801 MiB for 8,750 MiB of actor memory. The bucket held the same data plus 2,064 MiB of golden snapshots, which stay as long as their templates exist. The snapshot cache did not shrink when the actors went away. atelet trims it on its own schedule.
The node copy is the part that people forget. After an asynchronous suspend, atelet keeps the files on the node for 30 minutes, as section 3.4 shows. Those files sit in a host directory that Kubernetes charges to no pod. A public issue reports a node that crossed its disk pressure threshold this way, after which the kubelet evicted the worker pods.
Our position: size the node disk for the memory of every actor that can suspend on that node inside 30 minutes, plus the snapshot cache. Size the bucket for the memory of every suspended actor, plus one golden snapshot for each template. For memory fidelity, count the memory that holds data and add about 1 percent: our node held 8,801 MiB for 8,750 MiB. Keep free space above both numbers. A files-only snapshot is far smaller. For an actor with one small file in its volume, it was 44 KiB on the node and 48 KiB in the bucket.
A bucket outside the cluster, and what distance costs
Every other run in this manual used a bucket in the cluster. For this part we moved the bucket out and added network delay. Figure 8.1b shows every cycle.
We measured this on the second x86 server guest (16 vCPU, 23 GB), with five cycles for each cell unless the table says less. One cycle is a suspend with --mode sync, a wait until the node copy is gone, and one request. atelet ran with an upload delay of 3 s and a detach delay of 10 s, for these runs only. The outside bucket was rustfs in a container on the same guest, outside the kind network. We added the delay with tc netem, and a connection from a node then took 20.3 and 100.4 ms. All 49 requests returned 200. Raw runs
| Bucket | Added round trip | Data in memory | Synchronous suspend, median (range) | Resume from the bucket, median (range) |
|---|---|---|---|---|
| In the cluster | none | 0 MiB | 0.52 s (0.44 to 0.55) | 0.25 s (0.22 to 0.28) |
| In the cluster | none | 200 MiB | 2.07 s (1.83 to 2.21) | 1.04 s (0.91 to 1.09) |
| In the cluster | none | 1,000 MiB | 6.44 s (5.32 to 7.11) | 4.50 s (4.49 to 4.84) |
| Outside | none | 0 MiB | 0.47 s (0.45 to 0.55) | 0.25 s (0.22 to 0.34) |
| Outside | none | 200 MiB | 2.14 s (1.84 to 2.31) | 1.08 s (1.03 to 1.10) |
| Outside | none | 1,000 MiB | 7.09 s (5.80 to 7.23) | 4.79 s (4.54 to 4.95) |
| Outside | 20 ms | 0 MiB | 0.66 s (0.61 to 0.76) | 0.37 s (0.35 to 0.45) |
| Outside | 20 ms | 200 MiB | 2.70 s (2.44 to 2.81) | 2.50 s (2.49 to 2.71) |
| Outside | 20 ms | 1,000 MiB, 4 cycles | 8.39 s (7.17 to 8.90) | 10.24 s (9.62 to 10.87) |
| Outside | 100 ms | 0 MiB | 1.53 s (1.41 to 1.59) | 0.94 s (0.86 to 1.06) |
Three results come out of the table. Moving the bucket out of the cluster, with no delay, changed little. A round trip of 20 ms more than doubled the resume at 200 and at 1,000 MiB, and the suspend grew less. At 100 ms even an actor with no data paid: its resume went from 0.25 s to 0.94 s.
Read the limits before you use these numbers. The bucket ran on the same guest, so the delay was emulated, and the bucket shared one disk and one CPU with the node. Bandwidth was not limited. Two cells at 100 ms were missing in that run, and the next part has them. The fifth cycle of 1,000 MiB at 20 ms is still missing. The next part also moves the bucket to another machine.
Our position: measure the round trip from your nodes to your bucket before you promise a resume time. Keep the bucket in the region of the cluster. An actor that must wake fast needs its node copy. Set the detach delay to the longest idle time of that actor, as section 3.4 describes.
A bucket on another machine
We then put the bucket on another machine. Figure 8.1c shows every cycle.
We measured this on the second x86 server guest (16 vCPU, 23 GB), with five cycles for each cell unless the table says less. The bucket was rustfs in a container on another guest of the same server. Raw runs
The method was the one above, with one change. When a request returned 504, the client sent it again each second until the first 200, and it sent no revert. So the last column is an upper value, about 1 s coarse. All 51 cycles ended with 200 and the right count.
The path decides every number here. The two guests reach each other through the user-mode network of the hypervisor. A connection from a node took 0.7 to 1.8 ms in five tries. A copy of 300 MiB took 3.9 s up and 4.5 s down, which includes about 1 s to start the client. That is about 100 MiB each second.
| Path to the bucket | Data in memory | Synchronous suspend, median (range) | First answer | Request to the first 200, median (range) |
|---|---|---|---|---|
| Plain HTTP | 0 MiB | 0.49 s (0.47 to 0.56) | 200 | 0.28 s (0.26 to 0.30) |
| Plain HTTP | 200 MiB | 3.07 s (3.00 to 3.15) | 200 | 2.64 s (2.63 to 2.68) |
| Plain HTTP | 1,000 MiB | 13.10 s (11.73 to 14.06) | 504 after 10.05 s, 5 of 5 | 12.15 s (11.44 to 12.45) |
| TLS | 0 MiB | 0.48 s (0.46 to 0.52) | 200 | 0.26 s (0.25 to 0.32) |
| TLS | 200 MiB | 4.96 s (4.64 to 5.01) | 200 | 2.54 s (2.48 to 2.61) |
| TLS | 1,000 MiB | 20.78 s (20.12 to 22.35) | 504, 5 of 5 | 12.45 s (12.24 to 12.72) |
| Plain HTTP, limited to 100 Mbit/s | 200 MiB | 18.63 s (18.42 to 18.90) | 504, 5 of 5 | 18.06 s (17.90 to 18.20) |
| Plain HTTP, limited to 20 Mbit/s | 200 MiB | 88.50 s (88.32 to 88.55) | 504, 5 of 5 | 87.80 s (87.79 to 87.86) |
| Plain HTTP, 100 ms added | 0 MiB | 1.54 s (1.35 to 1.79) | 200 | 1.98 s (1.46 to 2.15) |
| Plain HTTP, 100 ms added | 200 MiB | 107.4 s (106.8 to 107.6) | 504, 5 of 5 | 55.1 s (54.7 to 55.2) |
| Plain HTTP, 100 ms added | 1,000 MiB, 1 cycle | 424.2 s | 504 | 213.4 s |
Four results come out of the table.
- Another machine costs more than another container. With no shaping, 200 MiB resumed in 2.64 s, against 1.08 s with the bucket on the same guest. 1,000 MiB took 12.15 s against 4.79 s.
- A resume that passes 10 s returns 504 first. In 26 of these 51 cycles the first answer was a 504 after 10.05 s. The resume went on, and a later request returned 200 with the state. Section 4.4 explains the limit.
- Bandwidth sets the time once the snapshot is large. At 20 Mbit/s, 200 MiB took 88 s in each direction. That is close to the size divided by the rate.
- A synchronous suspend has no fixed limit while data moves. One suspend ran 424 s and succeeded.
Do not read the rows with 100 ms as the rows of a real link. On this path, the same copy of 300 MiB took 55 s in each direction with the delay, which is about 5.5 MiB each second. The user-mode network passes each connection through a process on the host, and delay cut its rate by a factor of about 18. We did not measure a real link with 100 ms, so we cannot give its numbers.
We measured the two cells that the earlier run missed, with the method of that run and five cycles each. The bucket was in a container on the same guest, with 100 ms added. At 200 MiB the synchronous suspend took a median of 7.17 s (5.26 to 8.43). The request took 8.74 s (6.98 to 12.83) to its first 200. At 1,000 MiB the suspend took 14.17 s (12.89 to 14.66), and the request took 16.16 s (16.05 to 16.47). Six of these ten requests got a 504 first. Raw runs
So the same 200 MiB with the same delay took 8.74 s on one path and 55.1 s on the other.
TLS to the bucket
Substrate v1.0.0 has no install setting for the CA of a bucket. The code builds the client of the object store plugin from the default configuration of the AWS SDK. That SDK reads the variable AWS_CA_BUNDLE. So we gave the plugin a CA with a patch.
kc -n ate-system create configmap x-store-ca --from-file=ca.crt=$HOME/x/tls/ca.crt --dry-run=client -o yaml | kc apply -f - >> 11-patches.log 2>&1
for w in ds/atelet deploy/ate-api-server; do
i=$(idx $w objectstore-plugin initContainers)
…
kc -n ate-system patch $w --type=json -p "[{\"op\":\"add\",\"path\":\"/spec/template/spec/volumes/-\",\"value\":{\"name\":\"x-store-ca\",\"configMap\":{\"name\":\"x-store-ca\"}}},…{\"op\":\"add\",\"path\":\"/spec/template/spec/initContainers/$i/env/-\",\"value\":{\"name\":\"AWS_CA_BUNDLE\",\"value\":\"/x-store-ca/ca.crt\"}}]" >> 11-patches.log 2>&1In the script, kc is kubectl with the kubeconfig of the cluster. The plugin runs in atelet and in ate-api-server, so the patch goes to both.
We measured the result on the same guest. A client with the CA completed a handshake with TLS 1.3, and curl without the CA stopped with exit code 60. In the table, TLS cost time in the suspend only. A suspend of 200 MiB took 4.96 s against 3.07 s, and one of 1,000 MiB took 20.78 s against 13.10 s. The resume times stayed inside the range of the plain path. Raw runs
Our position: treat this patch as your own change, and check it after each ate-setup deploy and each upgrade. The installer does not know the variable, so a new deploy can write the container without it.
What we did not test
Our object store was rustfs in every run. It ran in the cluster, in a container on the same guest, and on another guest of the same server. We did not test Google Cloud Storage or Amazon S3. We did not test a real link with 100 ms of delay. Our outside Postgres ran on the same guest and on another guest. We did not test a managed database, a Postgres restore, or a bucket restore.
So we give no tested backup procedure. Two backups at the same clock time are not one consistent backup. A suspend or a delete between them leaves rows and objects out of step.
When a dependency is down
A request to a running actor needs Postgres and ate-api-server and never needs the Kubernetes API server, and each loss has its own error text.
Every agent starts to fail at the same minute. The worker pods are healthy, and the actors were running a moment ago. You have three suspects: the database, the Substrate API server, and the Kubernetes API server. The response body tells you which one, if you know the three patterns.
When you finish this section, you can name the dependency that is down from the status code, the delay, and the body. You can also say how long recovery takes, and what a node restart does to actors.
Why a running actor needs the database
The router does not remember where an actor runs. For each request it calls ResumeActor on ate-api-server, and ate-api-server reads the actor row from Postgres. For a running actor the answer is the worker address, and the call costs one read. Section 1.2 follows that path. The Kubernetes API server is not on it. Kubernetes started the pods, and after that the request path does not ask it anything.
We tested the claim by removing each dependency in turn, with three request streams running.
- running: one request each second to a RUNNING actor,
e-run, from a pod with a client certificate. - suspended: one new SUSPENDED actor every 5 s, so each request needs a full resume.
- proxy: one request each second to
e-runthroughkubectl ate proxy, from outside the cluster.
We measured on a second x86 server guest (16 vCPU, 23 GB) with the Envoy dataplane. Before the outages, the running stream had a median of 27 ms (n=30). A fresh resume had a median of 411 ms (n=6). The client timeout was 9 s. Figure 8.2 shows every second of the three outages. Raw runs
Postgres down: a hang, then 500
We scaled the Postgres StatefulSet to zero for 61 s. The running stream failed from the next second. Each request waited until the client gave up at 9 s. Requests that were still open when Postgres came back returned a 500.
1794481295 running e-run 000 9.002294
…
1794481358 running e-run 500 2.311091 error resuming actor lab-e/e-run
…
1794481304 suspended e-s-009 503 5.013150 actor lab-e/e-s-009 unavailable
…
1794481454 running e-run 503 4.539254 actor lab-e/e-run unavailableWe measured 66 failures in 158 requests on the running stream, in one outage. The first failure came 1 s after the scale-down. The last came at 66 s, which is 3 s after Postgres reported ready. Resumes took longer to recover. New suspended actors failed until 90 s, which is 27 s after Postgres was ready. Their failures were a 503 after 5 s with actor lab-e/e-s-009 unavailable, or a 500 with error resuming actor. Raw runs
The 27 s matter. A dashboard that watches a running actor reports recovery half a minute before a new session can start.
At first that delay came from one run on one machine, and the runs below repeat it. A later drill ran on another machine, a second x86 server guest (16 vCPU, 31 GB), with Postgres away for about 30 s. There a suspended actor answered 0.4 s after Postgres was ready. The two runs differ in the machine and in the length of the outage, so we draw no difference between them. Plan for the longer delay. A failure drill shows the short case.
We measured the cause of the long delay in a third run, on the x86 server guest (24 vCPU, 47 GB, nested KVM), one outage. That cluster used a Postgres outside the cluster, and the database was away for 60.6 s. The running actor answered again 0.7 s after Postgres was ready. Resumes of suspended actors kept failing, and the last failed one was sent 28.7 s after ready. One failed resume in the ate-api-server log carries the text worker cache not ready. The log line worker cache synced came 29.5 s after ready. Raw runs
So the API server serves a running actor at once, and it cannot place an actor until its list of workers is full again. The delay was about the same with the bundled database and with the outside one, 27 s and 29 s. Section 8.1 has the install of that database.
We measured two more outages of about 63 s on a second x86 server guest (16 vCPU, 23 GB). The database ran on another guest of the same server. In the second outage a running actor failed 69 of 219 requests and answered again 5.2 s after Postgres was ready. Resumes failed in 18 of 44 requests, and the last failed one was sent 23.2 s after ready. Raw runs
The log of that outage shows why the delay is about half a minute. The worker cache of ate-api-server tried to read the worker list again after 1, 2, 4, 8, and 16 s. Its next try came 30 s later and succeeded, 26.9 s after Postgres was ready. So the delay is a retry timer that grew during the outage. It is not work that the API server must do.
We measured three more outages of the bundled database on the x86 server guest (24 vCPU, 47 GB, nested KVM). Each lasted about 63 s. They ran one after the other on a new cluster, with three running actors and the same streams. The worker cache was in sync again 28.1, 25.8, and 29.3 s after Postgres was ready. Raw runs
| Run | Running actor, served in the outage | Failures in the run, and their kind | Last failed resume, sent after ready |
|---|---|---|---|
| 1 | 0 of 63 | 70 of 215: 61 at the client limit of 9 s, and 9 with 500 | 24.4 s |
| 2 | 0 of 63 | 67 of 215: 58 at the client limit, and 9 with 500 | 24.8 s |
| 3 | 4 of 63 | 60 of 214: 55 with 503 unavailable after a median of 3.01 s, and 5 with 500 | 25.9 s |
The failure text changed from run to run on the same cluster. So do not tell a database outage from an API outage by the code alone. The time to the last failed resume did not change. We have seven outages of the database now, on two guests and with three placements of it. In each, the last failed resume was sent 23 to 29 s after Postgres was ready.
Two more signs point at the database. Both ate-api-server pods stayed in Running and dropped to 1 of 2 ready containers. The CLI then printed Error: failed to connect to ate-api-server: no ready api pods in ate-system. That text names the API server, and the cause was Postgres. Check the database first when you see it.
One running actor stayed at 504 until the router restarted
The first of those two outages ended in another way. We measured it once. After Postgres was back, one running actor served no request in the 121 s that we watched. In that run it failed 185 of 232 requests, each at the client timeout of 9 s. With a longer timeout, the next 8 requests each returned 504 after 10.01 s. Two other running actors answered in 0.04 s, and the API reported the actor as ACTOR_STATE_RUNNING.
The router logged ResumeActor for that actor and no result. A kubectl ate resume actor returned in 0.17 s and changed nothing. A synchronous suspend worked in 0.67 s, and the next two requests still returned 504. We restarted the atenet-router Deployment 357 s after Postgres was ready, and the restart took 7 s. The next request returned 200 in 0.33 s with count 49, so the actor had kept its state.
We did not find the cause. It happened in 1 of these 2 outages. It did not happen in the outage of the database on the same machine, or in four outages of the bundled one. After each of the last three, we sent three requests to each of six running actors, and 18 of 18 returned 200 each time. So we saw it in 1 of 7 database outages.
Our position: after a database outage, send one request to each running actor that matters. If one returns 504 after 10 s while the others answer, restart atenet-router. Do not revert that actor, because its state is intact.
ate-api-server down: 503 in 3 to 5 seconds
We scaled the ate-api-server Deployment to zero for 60 s. Every stream returned the same body, actor lab-e/e-run unavailable, with status 503.
We measured 60 failures in 166 requests on the running stream, in one outage. A failed request took a median of 3.01 s and at most 5.02 s. The 5 s is the parking budget of the router, which section 4.4 explains. The router holds the request and tries again until that budget ends. Raw runs
The first failure came 6 s after the scale-down, and the last came 5 s after the scale-up. Recovery needs no action. The router finds the new pods by itself.
We measured a second run on the x86 server guest (24 vCPU, 47 GB, nested KVM), once, with the API pods gone for 70.1 s. The running stream failed 75 of 235 requests, all 503 unavailable, with a median of 3.01 s and a maximum of 5.02 s. The first failure came 5 s after the scale-down, and the last came 5 s after the scale-up. That is the first run again, to the second. Raw runs
Compare the two patterns for the running actor of this run. Without Postgres the client waited past its timeout. Without ate-api-server the client got a 503 within 5 s.
Do not read more into the body than it says. A 503 with the word unavailable and nothing after it means that the router got no answer from ate-api-server. It does not say why. The same body appeared in the Postgres outage: ten resumes of suspended actors got it in this run. In the drill of section 9.4, a running actor and a suspended actor both got it while Postgres was away. So when you see this body, check both: that the ate-api-server pods are ready, and that Postgres is up.
Figure 8.2c plots the delay of every request in the running stream.
Kubernetes API server down: nothing, except the proxy
We moved the kube-apiserver manifest out of the node for 86 s. kubectl printed Unable to connect to the server: EOF.
We measured 86 of 86 requests served on the running stream during the 86 s outage, and 17 of 17 resumes of suspended actors. That is 103 of 103 requests from inside the cluster. A wider window has 223 of 223. It runs from 10 s before the outage to the end of the run, 95 s after the manifest was back. A resume needs a worker, a snapshot, Postgres, and ate-api-server. It does not need Kubernetes. Raw runs
We measured the running actors again on the x86 server guest (24 vCPU, 47 GB, nested KVM), once. The API server was away for 90.7 s. One running actor served 91 of 91 requests in the outage, and two more served 182 of 182. The resumes of that run are not valid, because the running actors of the earlier runs had filled the pool. So the 17 of 17 resumes above stay one run. Raw runs
The proxy stream is the exception. kubectl ate proxy reaches the router through a port-forward, and a port-forward goes through the Kubernetes API server. It served 0 of 81 requests during the outage, and it kept failing for 21 s after the API server was healthy. It failed 105 requests in all. Every kubectl ate command failed the same way.
Our position: never judge the health of your actors through kubectl ate proxy. Probe from a pod inside the cluster with a client certificate, as section 5.1 describes. A probe through the proxy reports a Kubernetes outage that your users did not see.
The same three outages on agentgateway
We repeated the three outages on the agentgateway dataplane, with the same three streams. The claim holds on both dataplanes. Postgres and ate-api-server are on every request path, and the Kubernetes API server is on none. Two things differ: the body of a failure, and the time until a running actor answers again.
We measured one run for each outage on a second x86 server guest (16 vCPU, 23 GB), with one kind node and agentgateway v1.7.0. Before the outages, the median was 21 ms for the running stream (n=29) and 470 ms for a fresh resume (n=6). Postgres was away for 61 s. The running stream failed from 6 s, in 81 of 160 requests. A failed request took a median of 3.0 s and at most 5.0 s. The last failure came 21 s after Postgres was ready, against 3 s on Envoy. Raw runs
We measured the other streams in the same run on that guest, n=1 for each outage. New resumes failed from 1 s without Postgres, in 19 of 32 requests, until 26 s after it was ready. Without ate-api-server for 61 s, the running stream failed from 17 s, in 70 of 166 requests. Its last failure came 15 s after the API pods were ready. The Kubernetes API server was away for 87 s. In the whole window of that outage, 188 of 188 requests to the running actor and 38 of 38 new resumes were served. Raw runs
1794488149 running e-run 500 0.043341 ingress failed
…
1794488157 running e-run 504 3.010174 ingress failed
…
1794488178 running e-run 503 2.009588 ingress failed
…
1794488174 suspended e-s-013 503 5.018422 ingress failedOn Envoy the body named the actor and gave a hint: actor lab-e/e-run unavailable, or error resuming actor. On agentgateway every failure body was ingress failed, with status 500, 504, or 503. A client cannot tell the cause from the body. Read the status code and the delay, and check Postgres and the ate-api-server pods each time.
Figure 8.2e puts the running stream of both dataplanes on one time axis.
Plan for the longer recovery, and do not read it as a law of the dataplane. Each dataplane had one run. On the agentgateway run, a second cluster ran node tests on the same guest at the same time. We also did not find why the first failure after the ate-api-server scale-down came at 17 s here and at 6 s on Envoy.
The proxy stream failed here in the same way as on Envoy. Through kubectl ate proxy, 103 of 173 requests failed during the Kubernetes API outage and for 19 s after it.
One actor stayed in RESUMING
One request started a resume in the second that Postgres went away. That actor, e-s-007, never left the state it was in.
ATESPACE NAME TEMPLATE STATE WORKER POD WORKER IPS VERSION AGE
lab-e e-s-007 lab-e/e-counter ACTOR_STATE_RESUMING lab-e/e-pool-77cc8bf9f-p888v 10.244.0.37 2 9m54sFive minutes later it was still the only actor of 131 in ACTOR_STATE_RESUMING. It held a worker for all that time. We deleted it. We did not try RevertActor or a second resume on it, so we cannot tell you which one repairs it. After any database outage, list the actors and look for this state.
We measured the same thing in the three outages of the bundled database on the x86 server guest (24 vCPU, 47 GB, nested KVM). After each one, one more actor was in ACTOR_STATE_RESUMING: 1, then 2, then 3. The outages of ate-api-server and of the Kubernetes API server that followed added none. Our files hold the count of actors in each state and not their names, so we cannot say which request left each one. Raw runs
We measured two more in the burst run of section 4.5, with four replicas of ate-api-server and the bundled Postgres out of connections. Two actors got a 500 with error resuming actor. Both were still in ACTOR_STATE_RESUMING 4 s later and 4 min 12 s later. The next request to each, about 6 minutes after the 500, returned 200. Raw runs
The same run had a second kind of stall, with no outage. 4 of 2,800 burst requests got no answer before the client limit of 90 s. Each of those actors answered 200 in a later burst. We did not find the cause of either stall.
A node restart: the API returns in 8 s, the actors do not
A node restart removes every dependency at once, and the sandboxes and the worker registrations with them. We restarted the only node of the first test cluster with docker restart kind-control-plane. One actor, r1, was suspended with a snapshot that had not reached the bucket. Figure 8.2b shows the order of events.
2026-11-12T11:41:39.118Z kube-api-back 8 s
2026-11-12T11:43:58.809Z pods-ready 147 s, not-running=0
2026-11-12T11:44:00.610Z call-after-reboot 500 0.408692 error resuming actor lab-c/r1
…
2026-11-12T11:44:05.915Z call-again 503 0.070858 actor lab-c/r1 unavailable: AssignWorker prerequisite not met for Actor: lab-c/r1 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED)
…
2026-11-12T11:44:11.607Z call-after-revert 503 5.060362 actor lab-c/r1 unavailable: no worker has room for the actorWe measured this once, on an x86 server guest (24 vCPU, 47 GB, nested KVM). Three things happened that a pod restart does not prepare you for. Raw runs
- The first request came less than 2 s after all pods ran, and it crashed a suspended actor. The request returned 500, and
r1moved toACTOR_STATE_CRASHED. The crash record below does not name a lost snapshot. It says that the worker had not registered yet. RevertActorbrought it back to its last durable snapshot. The two calls after that snapshot were lost, as section 3.5 explains.- The request after the revert waited 5.06 s and returned
no worker has room for the actor. All pods had been running for 13 s. The same request returned 200 about 78 s later.
crash:
crashTime: "2026-11-12T11:44:00.591165797Z"
message: 'resume failed: atelet Restore: worker restore failed with a retryable
error 3 times in a row, giving up: while calling worker RestoreWorkload: rpc
error: code = Unavailable desc = worker has not registered yet: not accepting
workloads'A running pod is not a registered worker. The worker process must register with ate-api-server again before it accepts an actor. A request that arrives too early after a node restart can crash a suspended actor. A revert then returns it to the last durable snapshot.
A second restart, with one resume attempt every 2 to 3 s, gave the cause of the third result.
We measured it on the same x86 server guest (24 vCPU, 47 GB, nested KVM), one run. ate-api-server first answered 160 s after the restart command, and it listed all 11 workers as active. From 163 to 171 s it marked each of those workers WORKER_STATE_UNAVAILABLE. The first resume worked at 174 s, and all 11 workers were active again at 191 s. Raw runs
The control plane returns with the worker list from before the restart. Then it marks each of those workers unavailable, until the worker registers again. In that window the pool has no active worker, so a resume fails for lack of room or with the text is unavailable. The window was 10 to 30 s.
Our retry in the first run came 78 s later, so the window looked longer than it was. Plan for the delay, and do not read this 503 as a full pool. Section 4.3 has the full timeline.
An actor that was RUNNING during the restart, my-counter-1, was ACTOR_STATE_CRASHED afterwards. It returned the AssignWorker prerequisite 503 until a revert.
A node that stops and does not return
A restart ends. A node that stops and stays down is a different failure, and Substrate v1.0.0 does not repair it. On a cluster with two worker nodes, we stopped one of them in 13 cases. No actor left the stopped node by itself in any of them. This subsection is the runbook, and section 4.3 explains the placement rule behind it.
We measured each case once, on a second x86 server guest (16 vCPU, 23 GB), with the Envoy dataplane, gVisor, and the default timers. The pool had two worker pods on each worker node, and the other node had free workers in every case. The test actor held a counter and 50 MB in memory, so the count in a reply shows which snapshot it came from. The workers of the stopped node showed WORKER_STATE_UNAVAILABLE 45 to 52 s after the stop command, in five cases. Kubernetes evicted the pool pods after 342 to 350 s, in two cases. An actor that was ACTOR_STATE_RESUMING on the node became ACTOR_STATE_CRASHED at 342 to 350 s, in four cases. Raw runs
| The actor when the node stopped | What the client sees | The sequence that worked | Time to recover | What is lost |
|---|---|---|---|---|
Suspended with --mode sync, and no request during the outage | Nothing, because no request is sent | Wait until kubectl ate get worker shows the workers of the node as UNAVAILABLE. Then revert actor, then send the request | Revert 7.1 s. First request 200 in 0.56 s, on the other node | Nothing. The count continued from the snapshot |
The same, with a revert before the workers show UNAVAILABLE | The revert waits 20.2 s and fails on atelet Terminate. The actor is REVERTING, and a request gets 503 at once | None during the outage: 4 of 4 reverts failed. After the node and its atelet were back, one revert (0.2 s) and then the request | The outage, plus 17 s. The request returned 200 in 0.47 s | Nothing from the snapshot |
| Suspended, and a request arrives during the outage | A client timeout at 10 s, then 503 actor ... unavailable after about 5 s each. The actor is RESUMING on the stopped node, then CRASHED at about 345 s | Fast: create actor <new> --template <t> --tag <tag> with a tag from before the stop, then send requests to the new name. Slow: send no revert, wait for the node, then revert and request | With a tag: first request 200 in 0.58 s, 1.2 s after the create command. Without: the outage, then revert 0.3 s and request 200 in 0.90 s | With a tag: all work after the tagged snapshot, and the actor has a new name. Without: nothing |
The same, and a revert during the outage | The revert is refused while RESUMING, 19 of 19 times: not in a revertable state. At CRASHED one revert fails on atelet Terminate, and the actor is REVERTING. 0 of 144 requests were served | None during the outage. In two runs, 13 and 15 more reverts failed | Not recovered in the 579 and 602 s that we watched | The actor is not usable until the node returns |
Suspended in the default mode, with its newest snapshot still PENDING on the node | A revert after UNAVAILABLE fails in 0.8 s on atelet Terminate. The actor is REVERTING, and a request gets 503 | Send no command during the outage. After the node returns, send the request with no revert | The workers were active 25 s after the node start. The request returned 200 in 0.36 s | Nothing: the count was 6 after 5. With a revert first, the two calls after the last durable snapshot are lost |
RUNNING | 503 upstream connect error or disconnect/reset before headers. The state stays RUNNING | Fast: a new actor from a tag that you made earlier. It works while the original is still RUNNING. Slow: wait for the node. The actor was CRASHED 18 s after the node start. Then revert and request | With a tag: 200 in 0.48 and 0.51 s, 1.2 s after each create command. Without: the outage, then revert 0.3 s and request 200 in 0.48 s | All work after the snapshot or the tag that you use |
RUNNING, and a revert during the outage | The revert fails on atelet Terminate, and the actor is REVERTING. 27 more reverts failed | None during the outage | Not recovered in the 718 s of the outage | The actor is not usable until the node returns |
Any actor on the node, and you send suspend, resume, or delete | suspend: MarkSuspending prerequisite not met. resume: worker ... assigned to actor ... is unavailable. delete: fails on atelet Terminate, and the actor is DELETING | None | Not during the outage. A new actor with the same name gets AlreadyExists | The name, until the node returns |
The pattern behind the table is one call. Every command that changes an actor on the stopped node calls atelet on that node: revert, suspend, resume, and delete. While the node is down, that call fails. A failed revert or delete also leaves the actor in ACTOR_STATE_REVERTING or ACTOR_STATE_DELETING, and that state stays until the node is back. Three rules follow.
Wait for UNAVAILABLE before a revert on a quiet actor. A quiet actor is suspended with --mode sync and has had no request since the stop. One revert, sent 45 s after the stop command, freed such an actor, and its next request ran on the other node. In another run we sent the revert 10 s after the stop command. It failed after 20.2 s, and the actor was in ACTOR_STATE_REVERTING until the node came back.
Never revert or delete an actor that receives requests during the outage. The first request binds the actor to a worker of the stopped node, and from then on no command helps. The same rule covers an actor that was suspended in the default mode. Its newest state is on the node and comes back with the node, so a revert only throws that state away.
Make a tag before you need it. kubectl ate create tag <name> --actor <a> works on a suspended actor and returned Ready in less than 1 s. It also worked while the newest snapshot of the actor was still PENDING. create actor --tag was the one rescue that worked in every case, 3 of 3. The new actor served its first request 1.2 s after the create command, on the other node. The price is a new actor name, so your client must be able to change the name that it calls.
2026-11-12T14:16:17.202Z t=60.4 tag-attempt-on-stuck-actor state=ACTOR_STATE_RESUMING | Error: failed to create tag: rpc error: code = FailedPrecondition desc = Actor lab-n/t1 must be ACTOR_STATE_SUSPENDED to be tagged (got: ACTOR_STATE_RESUMING)
…
2026-11-12T14:16:19.269Z t=62.5 rescue-first-request 1.2 s after create | 200 0.581895 count=4 mb=50 sum=6613 process_age_s=4.9 | ACTOR_STATE_RUNNING multi-worker SNAPSHOT_OWNER_TAG:SNAPSHOT_STORAGE_STATE_COMPLETED:gen1:-
2026-11-12T14:16:19.765Z t=63.0 rescue-second-request 200 0.064394 count=5 mb=50 sum=6613 process_age_s=5.2 | ACTOR_STATE_RUNNING multi-worker SNAPSHOT_OWNER_TAG:SNAPSHOT_STORAGE_STATE_COMPLETED:gen1:- pod lab-n/ballast-5d6cf97747-zzzdnThe first line is the reason for the third rule. A tag cannot be made after the fact. On the original actor, create tag failed with another operation is in progress for this actor, and then with the text in the listing. Figure 8.2d puts the runbook on one page.
When the node comes back
We measured the return of the node in the same cases, on the same guest (16 vCPU, 23 GB), one return for each case. Kubernetes reported the node Ready 2.6 to 4.7 s after the start. After an outage shorter than the eviction time, 51 to 263 s, the node still had its two pool pods. Its workers were active again after 24 to 31 s, in four returns. The first new actor went to the node 23 to 45 s after the start, in five returns. Raw runs
We measured seven returns after an outage longer than the eviction time, 372 to 718 s, on the same guest. The node came back with no pool pod. The replacement pods ran on the other node and stayed there. No new actor went to the returned node in the 216 to 223 s that we watched. We deleted pods ourselves, so that each node had two again. Raw runs
So Substrate does not rebalance a pool. After a long outage the returned node stays empty until someone moves pods. An actor that a command left in ACTOR_STATE_REVERTING needs one more revert after the return, and section 3.5 has that result and its caveat.
A node that you drain
A drain is the planned form of a node stop, and it ends differently. kubectl drain evicts the worker pods and leaves the DaemonSet pods, so atelet stays on the node. The control plane can still reach the snapshot files there. Figure 8.2f shows the five cases that we ran.
We measured each case once, on the second x86 server guest (16 vCPU, 23 GB), with Envoy and gVisor. The cluster had two worker nodes. The command was kubectl drain with --ignore-daemonsets --delete-emptydir-data --force. It returned in 1.2 to 2.2 s, in five of five cases. Raw runs
The pool had no PodDisruptionBudget. In the two cases with no request during the drain, the new pods ran on the other node 2.5 s after the command.
| The actor when the drain started | What the client saw | Did it move by itself | What worked | What was lost |
|---|---|---|---|---|
RUNNING, one request about each second | 503 from 0.4 s. 46 of 62 requests failed, most with AssignWorker prerequisite not met | No. It was ACTOR_STATE_CRASHED | One revert, 0.2 s. The next request returned 200 0.8 s later, on the other node | The 6 calls after the last durable snapshot |
Suspended with --mode sync, no request during the drain | Nothing | Yes, at its first request | Send the request. It returned 200 in 0.58 s | Nothing |
Suspended with --mode sync, one request about each second from 2 s after the drain | 30 of 30 requests returned 200. The first took 0.49 s | Yes | No command | Nothing |
Suspended in the default mode, newest snapshot still PENDING on the node | Nothing | Yes, at its first request | Send the request. It returned 200 in 1.19 s | Nothing. The count was 6 after 5 |
RUNNING, with one request of 120 s open | The open request got 503 connection termination 0.4 s after the drain command. Then 16 of 16 requests got 503 | No. It was ACTOR_STATE_CRASHED | One revert, 0.2 s. The next request returned 200 0.7 s later | The open request, and the 2 calls after the last durable snapshot |
The script sent the revert 64.8 s after the drain in the first case and 24.8 s after it in the last. We did not test an earlier revert.
Two results stand out. A drain does not suspend a running actor. It ends the worker pod, and the actor was ACTOR_STATE_CRASHED within 7 s, in two of two cases. The worker pods had a termination grace period of 3,600 s, and the drain still returned in about 2 s.
A drain does not hurt a suspended actor, in three of three cases. This is the opposite of a node stop. After a stop, the first request binds the actor to a worker of the dead node. After a drain, the node has no worker, and its atelet still answers. So the resume moves the snapshot off the node and places the actor on the other node. In the quiet case the drained node held 52 MiB of snapshots before the request and 1 MiB after it.
Our position: before a drain, suspend each running actor with --mode sync, and then drain. A suspended actor moves with its state at the next request. A running actor crashes, and a revert returns it to the last durable snapshot. Section 8.4 gives the same order for a worker upgrade.
After kubectl uncordon the node stayed empty, in five of five returns. Its pool pods ran on the other node, and Kubernetes did not move them back. We created 15 new actors after each return, in 86 s. None of the 75 went to the returned node. We moved pods by hand again.
A stopped node, and no free worker on the other node
The runbook for a stopped node rests on a free worker on the other node. We filled the other node and ran it again. Each worker of that pool holds 1,984 Mi, and one actor with a 1,900 Mi limit fills it.
We measured two node-stop runs with the other node full, on the same guest (16 vCPU, 23 GB), each once. The revert after UNAVAILABLE was accepted in 2.8 and 3.1 s, and the actor was ACTOR_STATE_SUSPENDED. Each request then waited 5.05 to 5.07 s and got 503 with no worker has room for the actor, in 8 of 8 requests. kubectl ate resume actor failed with ResourceExhausted. create actor --tag was accepted, and a request to the new actor got the same 503. Raw runs
Capacity ended the wait in both runs. In the first run we suspended one actor on the full node, which took 0.6 s. The request then returned 200 in 0.80 s, 1.6 s after that command. In the second run we scaled the pool from 4 workers to 6. The request returned 200 in 1.55 s, 1.7 s after the scale command.
The new actors from the tag answered in 0.64 and 0.63 s. Nothing was lost in either run.
So a full node does not break the runbook. The revert and the rescue from a tag still succeed, and both then wait for a worker. Read no worker has room for the actor after a node loss as a capacity problem, and free a worker or add one. Section 4.3 has the same case with both nodes up.
The object store away
A resume from the node copy does not need the bucket. A synchronous suspend needs it, and so does a resume of an actor whose only copy is in the bucket. We took the bucket away in two ways. In the first, each connection was refused. In the second, packets were dropped, so each call waited.
We measured each case once, on the same guest (16 vCPU, 23 GB), with actors that hold no data in memory. The bucket for these runs was outside the cluster, as section 8.1 describes. Raw runs
| Operation while the bucket was away | Connection refused | Packets dropped |
|---|---|---|
| A request to a running actor | 200 in 0.06 s | not recorded |
| A request to a suspended actor whose only copy is in the bucket | 500 error resuming actor after 4.11 s. The actor is ACTOR_STATE_CRASHED | 504 after 10.05 s. The actor stays ACTOR_STATE_RESUMING |
| The same actor after the bucket was back | 503 on 22 of 22 requests. After one revert, 0.2 s, the request returned 200 in 0.33 s with its state | 200 in 0.63 s with no command, with its state. The bucket had been away for 137 s |
suspend --mode sync of a running actor | The call fails after 4.28 s at the step CallAteletSuspend. The actor is ACTOR_STATE_CRASHED | The call fails after 126.3 s. The actor is ACTOR_STATE_CRASHED |
| That actor afterwards | 503 until a revert. Then 200 from the last durable snapshot, and the 2 calls after it were lost | The same, and the 2 calls after the last durable snapshot were lost |
suspend in the default mode | 0.31 s. The snapshot stays PENDING on the node, and the next request returned 200 in 0.22 s | not recorded |
create actor from a template, then a request | 200 in 0.27 s | 200 in 0.24 s |
The row to remember is the synchronous suspend. With the bucket away, the call that you send to protect an actor crashes it. The actor was healthy and RUNNING before the call, and it needed a revert after it. The default suspend was safe in the same minute, because it does not wait for the upload.
The first two rows show a second difference. A refused connection crashed the suspended actor, and it stayed crashed after the bucket was back. A dropped connection left it in ACTOR_STATE_RESUMING, and it answered by itself when the bucket returned.
We measured the same cases again with actors that hold 200 MiB, once each, with the bucket on another guest. The bucket was stopped for 57 s. In a second pass its packets were dropped for 176 s. Raw runs
| Operation | Bucket stopped | Packets dropped |
|---|---|---|
| A request to a running actor | 200, 2 of 2 | 200, 2 of 2 |
| A request to a suspended actor whose only copy is in the bucket | 500 error resuming actor after 3.87 s, then 503 | 504 after 10.05 s, twice |
| The same actor after the bucket was back | 503 with no command. After one revert, 0.2 s, 200 in 2.52 s with its count | 503 with no command. After one revert, 0.3 s, 200 in 2.74 s with its count |
suspend --mode sync of a running actor | The call fails after 1.62 s at the step CallAteletSuspend. The actor is ACTOR_STATE_CRASHED | The call fails after 103.05 s. The actor is ACTOR_STATE_CRASHED |
That actor after a revert | 200 in 2.50 s. Its count was 5 before the suspend and 4 after, so 2 calls were lost | 200 in 2.60 s, and 2 calls were lost |
suspend in the default mode | 0.83 s. The snapshot stays PENDING on the node, and the next request returned 200 in 0.39 s | 0.70 s, and the next request returned 200 in 0.49 s |
| That actor after the bucket was back | 200 in 0.40 s from the node copy, with its count | 200 in 2.41 s from the bucket, with its count |
create actor from a template, then a request | 200 in 0.60 s | 200 in 0.47 s |
Two results hold with data in memory. The synchronous suspend again crashed a healthy actor, and the 2 calls after its last durable snapshot were lost, in 2 of 2 cases. The default suspend lost nothing, in 2 of 2 cases.
One result changed. With packets dropped, the actor whose only copy was in the bucket ended crashed, and it needed a revert. In the first run, the same case with no data in memory recovered with no command. So do not count on that recovery.
Our position: check that the bucket answers before a batch of synchronous suspends, such as the batch before a drain or a worker upgrade. If it does not answer, leave the actors running, or suspend in the default mode and accept the node copy.
What we did not test
Each node-stop case, each drain case, and each bucket case ran once. On Envoy the Postgres outage ran seven times in four setups, and the other two outages twice each. On agentgateway each outage ran once. Every outage ran on one kind node. We did not test egress from an actor during an outage, or a connection that stays open across one.
The node-stop cases have four gaps. A stop here was docker stop, which ends the kubelet and atelet cleanly, so a power loss or a network loss can differ. We did not change the node timers of Kubernetes, use more than two worker nodes, or repeat the cases on agentgateway. We filled the other node by memory, and not by CPU or by a count of actors.
The drain cases have three gaps. The pool had no PodDisruptionBudget. We did not drain the node that runs ate-system. We sent no revert in the first 20 s after a drain.
The bucket cases with data in memory ran once each, with 200 MiB. We did not record when a pending snapshot is uploaded after a stopped bucket returns. We did not find why one running actor stayed at 504 after a database outage. We did not test a database outage with dropped packets.
Symptom, cause, fix
Each Substrate failure that we recorded has a fixed text, and the text, the status code, and the delay together name one cause and one fix.
An agent fails and your client logs a 503. Substrate returns a 503 for a full pool, a crashed actor, a dead process, and an API server that is away. The four cases need four different actions. The status code does not separate them. The body and the delay do.
When you finish this section, you can take an error string from your log, find its row here, and apply the fix. Every string in the tables came from one of our runs, and each row names the section that explains the mechanism. The error reference lists the same strings in alphabetical order.
Start from the status code and the delay
Figure 8.3 sorts the failures of one request to an actor. Measure how long the failed request took before you read the body. A failure in under 0.1 s came from a decision. A failure at 5 s came from a wait that ran out.
A request to an actor fails
The error bodies that this manual records are from the Envoy dataplane, unless a row says otherwise. On agentgateway the status codes are the same and the bodies are not, as the part after the table explains.
| Symptom | Code and delay | Cause that we proved | Fix | Section |
|---|---|---|---|---|
actor lab-c/b0-5 unavailable: no worker has room for the actor | 503 after 5.06 s | Each worker of the pool had its CPU reserved by running actors. The router held the request for its 5 s budget. | Suspend actors that do no work, or add workers. Retry: a worker that became free 2 s into the wait served the request. | 4.4, 4.5 |
| The same text, soon after a node restart | 503 after 5.06 s | The control plane had marked each worker from before the restart unavailable, and the workers had not registered again. The window was 10 to 30 s in our second run. | Retry for one minute before you add workers. | 4.3, 8.2 |
actor xa/x2 unavailable: no worker has room for the actor, for an actor of a new template that has no golden tag | 503 after 5.06 s | The workerSelector of the template matched no WorkerPool. Substrate accepted the template, and the text is the text of a full pool. | Compare the selector with the labels of the pool. Create a new template with the right labels, because a template cannot change. | 3.6 |
504 with an empty body, then actor lab-n/u1 unavailable after 5 to 8 s, with the actor in ACTOR_STATE_RESUMING | 504 after 10 s, then 503 | The node of a suspended actor had stopped. The actor stayed bound to that node, in ACTOR_STATE_RESUMING for 5 min 33 s from its first request. It was crashed 5 min 44 s after the stop command. It did not move to another node. | Send no revert during the outage: it was refused 19 of 19 times, or it left the actor in ACTOR_STATE_REVERTING. Create a new actor from a tag that you made before the stop, which took 1.2 s. Or bring the node back, then revert. | 8.2, 4.3 |
504 with an empty body on the first request to a suspended actor, with the actor in ACTOR_STATE_RESUMING and its node up | 504 after 10.05 s | The resume took more than 10 s, because the snapshot was large and the bucket was far or slow. Envoy stopped its wait for the router, and the resume went on. | Send the request again after a pause, and send no revert. In 32 of 32 cycles a later request returned 200 with the state. | 4.4, 8.1 |
| 504 with an empty body on every request to one running actor after a Postgres outage, while other actors answer | 504 after 10.01 s | Not found. The router logged ResumeActor for the actor and no result. We saw it in 1 of 2 outages of a database on another machine. | kubectl rollout restart of the atenet-router Deployment. The next request returned 200 in 0.33 s with the state. A resume command and a suspend did not help. | 8.2 |
AssignWorker prerequisite not met for Actor: lab-c/r1 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED) | 503 in 0.07 s | The actor is crashed. Substrate resumes only a suspended actor. | kubectl ate revert actor, then send the request again. | 3.5 |
error resuming actor lab-c/x1 | 500 in 0.08 s | The snapshot was on one node only, and the node copy was gone. The request moved the actor to crashed. | kubectl ate revert actor. The calls since the last durable snapshot are lost. | 3.4, 3.5 |
error resuming actor lab-c/r1, soon after a node restart | 500 in 0.41 s | The request arrived before the workers had registered again. It moved a suspended actor to crashed. | kubectl ate revert actor. Hold requests until the workers are active. | 8.2 |
error resuming actor lab-e/e-run, after a client timeout | 500 after 0.3 to 8.3 s | Postgres was away. ate-api-server could not read the actor. | Restore the database. Requests recover in seconds, resumes in half a minute. | 8.2 |
error resuming actor sc-lab/t-128, in a burst, after ate-api-server went to four replicas | 500 after 0.66 to 1.35 s | The bundled Postgres ran out of its 100 connections. The API server log has FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute (SQLSTATE 53300) or FATAL: sorry, too many clients already. Some actors were crashed. | Scale ate-api-server back to two replicas, and revert the crashed actors. We did not test a higher connection limit. | 4.5 |
actor lab-e/e-run unavailable, with nothing after it | 503 after 3 to 5 s | The router got no answer from ate-api-server. We saw this text with ate-api-server away, and also with Postgres away, for a running and for a suspended actor. | Check both: that the ate-api-server pods are ready, and that Postgres is up. The text does not separate the two. | 8.2 |
upstream connect error or disconnect/reset before headers. reset reason: connection termination | 503 in about 0.06 s | The process in the sandbox exited. The actor still reads ACTOR_STATE_RUNNING. | Suspend with --mode sync, which fails and marks the actor crashed. Then revert. | 3.5 |
The same text, on a request that was open, with the actor ACTOR_STATE_SUSPENDED afterwards | 503 after 2.2 s, at the moment of the suspend | Something suspended the actor during the request. A suspend does not wait for open requests, and the suspend call reports success. | Send the request again, and it resumes the actor. Suspend only an actor that is idle. | 3.3 |
The same text, with the worker of the actor WORKER_STATE_UNAVAILABLE | 503 after 0.50 to 5.07 s | The node of a running actor had stopped. The delay separates this row from a process that exited. The actor read ACTOR_STATE_RUNNING for the 10 min of our probe. | Send no revert and no delete. A revert failed with while calling atelet Terminate and left the actor in ACTOR_STATE_REVERTING. Create a new actor from an earlier tag, or bring the node back and then revert. | 8.2, 3.5 |
AssignWorker prerequisite not met for Actor: lab-n/q2 (got: ACTOR_STATE_REVERTING, want ACTOR_STATE_SUSPENDED) | 503 in 0.06 s | A revert started while the node of the actor was down, and it did not finish. | Bring the node back, wait for its atelet, then send the revert again. That worked in 0.2 s in one run. More reverts during the outage all failed, 3 to 28 of them in five runs. | 3.5, 8.2 |
The same text with got: ACTOR_STATE_DELETING | 503 in 0.07 s | A delete ran while the node of the actor was down, and it did not finish. A new actor with the same name gets AlreadyExists. | Bring the node back. Until then the name is blocked. | 8.2 |
ingress failed on agentgateway, with status 500, 504, or 503 | 0.04 to 5.0 s | Postgres or ate-api-server was away. On agentgateway the body is the same for both, and for an unknown actor. | Check that the ate-api-server pods are ready and that Postgres is up. A running actor answered again 21 s after Postgres was ready. | 8.2 |
actor ate-demo-counter/does-not-exist not found on Envoy, ingress failed on agentgateway | 404 in 0.02 s | No actor has that name in that atespace. | Check the ate-target-actor header. | 6.1 |
On agentgateway, read the status code and the header
On the agentgateway dataplane, the body does not name the cause. Every ingress failure of Substrate returns the body ingress failed, with one exception. A full pool returns no worker has room for the actor, without the actor ... unavailable: text in front of it.
| What you have on agentgateway | Where to read it | What it tells you |
|---|---|---|
| The status code | the response | The codes are those of the table above: 404, 503, 504, 500, 401, and 403. |
The response header ate-error-reason | the response | A reason code, when the gateway has one. The code at v1.7.0 sets LOT_FULL for a full parking lot and API_UNAVAILABLE when ate-api-server did not answer. |
The log line substrate ResumeActor failed | the log of atenet-router | The gRPC code and the full message of ate-api-server, such as Actor ate-demo-counter/does-not-exist not found. |
| The actor state | kubectl ate get actors | It separates a crashed actor from a full pool, as on Envoy. |
The header rule is from the code. Our recordings on agentgateway kept the status code and the body, and they did not keep the response headers. The log line is from our run: the gateway logged the full message for the 404 that returned ingress failed. So on agentgateway, match on the status code and the header, and never on the body.
Three rows share the text error resuming actor. Separate them with the actor state. After a lost node copy or an early request after a restart, the actor is ACTOR_STATE_CRASHED. After a database outage a running actor is still ACTOR_STATE_RUNNING, and the next request succeeds.
The process exit is the case that hides longest. The state stays RUNNING, so a list of actors shows no problem.
2026-11-12T11:30:45.229Z j1 exit-request 200 0.065314 exiting with code 3
2026-11-12T11:30:47.441Z j1 state ACTOR_STATE_RUNNING kind-control-plane SNAPSHOT_OWNER_ACTOR:SNAPSHOT_STORAGE_STATE_COMPLETED:gen2:kind-control-plane
2026-11-12T11:30:47.518Z j1 call 503 0.058459 upstream connect error or disconnect/reset before headers. reset reason: connection terminationWe measured this on the x86 server guest (24 vCPU, 47 GB, nested KVM), with three actors. The first read RUNNING in 6 of 6 samples over 14 s, and every request returned this 503. Two more read RUNNING for 20 minutes 36 s, in 81 and 80 samples, and 20 of 20 requests returned this 503. In each case a suspend then failed with workflow failed at step CallAteletSuspend: actor lab-c/j1 crashed, or the same text for the other actor, and only then did the state change. Each dead actor kept one CPU of its worker until that suspend. Raw runs
A process that the kernel killed for memory was different. That actor read ACTOR_STATE_CRASHED at the second sample, 4 s after the kill.
Our position: do not wait for Substrate to report a dead process. Have your harness check two things when it sees this 503 body: the actor reads ACTOR_STATE_RUNNING, and its worker is active. Then the harness runs the suspend and the revert itself. With the worker WORKER_STATE_UNAVAILABLE the node is down, and a revert makes the case worse.
The install fails
| Symptom | Cause that we proved | Fix | Section |
|---|---|---|---|
unknown flag: --platform, then Error: while building the envoy-dataplane image: exit status 125 | The install from source builds the Envoy image with docker buildx. The stock Docker package of Ubuntu 24.04 has no buildx plugin. | Install from the release images. To build from source, install docker-buildx first. | 2.1 |
Error: unknown flag "-A" from kubectl ate get workers -A | Workers are not scoped to an atespace. The command lists the workers of all namespaces by default. Its filters are -n, -l, -a for the workers that host actors of one atespace, and --sandbox-class. | Run kubectl ate get workers with no flag. | 2.2 |
unknown flag: --platform
…
Error: while building the envoy-dataplane image: exit status 125The step failed after 163 s, so the error arrives late. The documented list of tools is Go, kubectl, and Docker, and it does not name buildx.
The actor cannot reach a host
| Symptom | Dataplane | Cause that we proved | Fix | Section |
|---|---|---|---|---|
status: NXDOMAIN for every name | both | No rule of the egress policy names that host. The DNS relay answers only for allowed names. | Add a rule with the full host name. | 6.3 |
HTTP 403, egress denied | Envoy | The request went to an address that no rule allows. | Add the host to the policy, and call it by name. | 6.2 |
HTTP 403, actor egress policy denied | agentgateway | The same cause. The two dataplanes use different texts. | The same fix. | 6.2 |
| A connection reset, with no HTTP response | Envoy | The actor has no egress policy at all. | Create a policy for the actor. | 6.2 |
egress_policy.rules[2].tls_passthrough.ports: Required value | CLI | A pass-through rule has no default port. | Add ports to the rule. | 6.2 |
lacks the metadata.uid and metadata.version preconditions | CLI | An update must name the version that it replaces. | Copy both fields from get egress-policy -o yaml. | 6.2 |
HTTP 503, upstream connection failed, for a host under an https rule | agentgateway | The host has a certificate from a private CA. The gateway logged invalid peer certificate: UnknownIssuer. | Mount a root file that holds your CA over the root file of the proxy container. Apply it again after each installer run. | 6.4 |
HTTP 503, upstream connect error or disconnect/reset before headers. reset reason: remote connection failure | Envoy | The same cause. The Envoy log had CERTIFICATE_VERIFY_FAILED. | The same fix. | 6.4 |
| HTTP 403 on a connection that worked a moment ago | both | A rule was removed. The next request on a reused connection is refused, about 1 s later on agentgateway and 5 to 9 s later on Envoy. | Put the rule back, or accept the refusal. | 6.6 |
| The upstream still gets the old secret after you changed the Kubernetes Secret | agentgateway | The gateway keeps a fetched credential for 300 s. The new value arrived after 100.6 to 280.5 s in three changes. | Wait 5 minutes, and keep the old value valid at the upstream for that time. | 6.5 |
| A model call takes 0.6 s more than the same call from a pod | both | The client opened a new HTTPS connection under an https rule. The handshake with the interception endpoint took about 0.6 s. | Reuse the connection. The same request took 10 to 12 ms on an open one. | 6.4, 7.2 |
A blocked host looks like a DNS failure, and that sends people to the wrong team. In the kagent run a short Service name, mock-llm.kagent:8080, returned 403 while the full name of the same Service returned 200. The policy matches the name that the actor asks for, so use the name exactly as the rule has it.
A client cannot connect, or a command is refused
| Symptom | Cause that we proved | Fix | Section |
|---|---|---|---|
tlsv13 alert certificate required | The client sent no certificate to the agentgateway router. | Give the client a certificate from the cluster signer. | 5.1 |
certificate unknown on Envoy, a reset on agentgateway | The client identity is not in the ingress allowlist. | Add the SPIFFE ID with --ingress-allowed-spiffe-ids. | 5.1 |
Error: failed to connect to ate-api-server: no ready api pods in ate-system | No API pod was ready. In one run Postgres was away. In another the node had restarted, and the CLI printed this text from 18 to 157 s after the restart. | Check Postgres before the API server. After a node restart, wait for the API pods. | 8.2 |
Unauthenticated desc = invalid bearer token | The file of --token-file held a text that is no token, or a Kubernetes token without the audience api.ate-system.svc. | Create the token with --audience api.ate-system.svc. | 5.1 |
PermissionDenied desc = permission denied: principal "user:system%3Aserviceaccount%3Aate-users%3Aalice" lacks "can_list_actors" on "atespace:lab-v" | The token is valid, and no access policy gives that principal a role that allows the call. | Add the principal to the access policy of the atespace. A scope of global:root needs a global role. | 5.2 |
FailedPrecondition desc = actor must be ACTOR_STATE_SUSPENDED to change its actor template (got: ACTOR_STATE_RUNNING) | kubectl ate update actor --template ran against a running actor. | Suspend the actor, then update it. | 3.1 |
InvalidArgument desc = actor_template.golden_egress_policy: Forbidden | The template uses a preview feature, and the server flag or the client flag for it is missing. The error does not name the feature. | Set --preview with the feature name on the server and pass --preview to kubectl ate. | 8.5 |
FailedPrecondition desc = Tag not found, from create actor --template | The golden tag of the template was deleted with kubectl ate delete tag. get actor-templates still lists the tag id, with an empty ERROR column. | Create a new template. Never delete a tag with the scope TAG_SCOPE_PUBLISHED that a template lists. | 3.4 |
failed to create tag: ... must be ACTOR_STATE_SUSPENDED to be tagged (got: ACTOR_STATE_RESUMING) | The actor is bound to a node that stopped. A tag cannot be made after the fact. | Make tags while the actor is suspended and its node is up. | 8.2 |
failed to revert actor: ... while calling atelet Terminate | The node of the actor is down. The revert needs atelet on that node, and the actor is now ACTOR_STATE_REVERTING. | Send no more reverts. Bring the node back, then revert once. | 3.5, 8.2 |
failed to suspend actor: ... MarkSuspending prerequisite not met ... (got: ACTOR_STATE_RESUMING, want ACTOR_STATE_RUNNING), or failed to resume actor: ... worker ... assigned to actor ... is unavailable | The actor is bound to a worker on a node that stopped. | No command helps during the outage. Use a tag, or wait for the node. | 8.2 |
SSL: no alternative certificate subject name matches target hostname 'atenet-router.ate-system.svc.cluster.local' | The client called the router by its long name. The router certificate is valid for the short name. | Call atenet-router.ate-system.svc. | 6.1 |
Unable to connect to the server: EOF | The Kubernetes API server was away. Actors still served requests. | Wait, and probe actors from inside the cluster. | 8.2 |
bind: address already in use from the kagent CLI | Each CLI call opens its own port-forward on port 8083. Calls that start together collide. | Keep one port-forward open, and point the CLI at it. | 7.1 |
… Error listen tcp4 127.0.0.1:8083: bind: address already in use unable to create listener: Error listen tcp6 [::1]:8083: bind: address already in use]
error: unable to listen on any of the requested ports: [{8083 8083}]): exit status 1We measured the kagent CLI on an x86 server guest (16 vCPU, 31 GB), in the kagent run. With one standing port-forward no call failed. A Session create took 0.17 s at the median (n=32), where it had taken 0.39 s (n=20) with a port-forward for each call. Raw runs
When no row matches
Collect four things before you ask for help or open an issue. Take the status code, the seconds, and the body of the failed request. Take the actor as kubectl ate get actor -o yaml prints it, which includes the state and each snapshot. Take the lifecycle log from kubectl ate logs actor --source lifecycle. Take the worker list from kubectl ate get worker, which shows the room in each pool.
Upgrade, and advice that no longer works
No release before 1.0.0 upgrades in place, and most instructions written for those releases fail at v1.0.0 in a way that this section names.
You search for an error and find a tutorial from three months ago. Its first command runs a script that your checkout does not have. Its second command passes a flag that the CLI rejects. The tutorial was correct for v0.1.0. Agent Substrate changed its installer, its API package, its states, and its request path before 1.0.0, and it kept no compatibility with the old forms.
When you finish this section, you can translate an old instruction into the v1.0.0 form. You can also say which upgrades the project supports, and in which order the components move.
Old instructions, and what they do now
The table lists each old instruction that we found in the v0.1.0 tree or in posts from that time. The middle column is what happens at v1.0.0. The code at the two tags is the source for each row, except where the text says that we ran it.
| Old instruction | What happens at v1.0.0 | What to do |
|---|---|---|
hack/install-ate-kind.sh --deploy-ate-system | The script is not in the repository. | Run ate-setup deploy ate-system, as section 2.1 shows. |
go run ./tools/setup-gcp bootstrap | The tool is not in the repository. It moved to a separate repository for GKE. | Follow section 7.3. |
kubectl ate create actor --template-ref counter | The flag does not exist. | Use --template counter. |
kubectl port-forward svc/atenet-router 8000:80, then a Host header | Nothing listens on port 80. The router reads the ate-target-actor header and requires a client certificate. | Use kubectl ate proxy actor, as section 2.3 shows. |
kubectl ate pause actor, PauseActor, ACTOR_STATE_PAUSED | The command, the method, and the state do not exist. | Use kubectl ate suspend actor. |
| A stored state number such as 7 for crashed | The numbers changed. Crashed is 5, deleting is 6, and reverting is 7. | Store the state name, never the number. |
apiVersion: ate.dev/v1alpha1 in a WorkerPool | The CRD serves v1 only, with no conversion. The apply fails. | Write ate.dev/v1. |
A Go import of pkg/proto/ateapipb | The module is go.ate.dev/substrate, and the package is api/ateapi/v1. | Generate the client again from the v1.0.0 proto file. |
A gRPC call to /ateapi.Control/GetActor | The service is substrate.ateapi.v1.Control. The old method name reaches nothing. | Use a client built from the v1.0.0 proto file. |
Template fields snapshotsConfig.onPause and sandboxConfig | The fields do not exist. A template has snapshotConfig.preferredFidelity, and the sandbox class comes from the WorkerPool. | Write the template as section 3.1 shows. |
| A template with no resource limits | The API requires cpuMillicores and memoryBytes under resources.limits. | Set both. |
A node label ate.dev/substrate-version | Nothing reads the label. | Remove the step. |
The installer flag --cordon-control-plane | The flag is --cordon-system-infra-components, and the node label changed with it. | Use the new flag and the label ate.dev/workloadType=ate-system-infra. |
Two rows deserve more than a cell.
The request path changed for security. At v0.1.0 any pod that reached the router Service could call any actor with a Host header. At v1.0.0 the router asks for a client certificate and checks its identity against an allowlist. We probed the old ports on both dataplanes. On agentgateway, ports 80 and 8081 refused the connection. On Envoy they are not in the Service, and the connection timed out.
The two dataplanes differ in what they list. With Envoy, the router Service has ports 443 and 8444 only. The agentgateway overlay adds ports 80 and 8081 to the Service and to the NetworkPolicy. Nothing listened behind them in our install. The gateway configuration that ate-setup installed declared ports 8443 and 8444 only, and the agentgateway process listened on those two. So on agentgateway the Service lists two ports that refuse every connection.
Its fifth port is different: the stats port 15020 answered /metrics to a pod in another namespace, with no credentials. The router and ingress has the port table and that note.
A tutorial that ends with a plain curl to the router cannot work. Do not look for a setting that brings the old path back.
The API renumbered its states. A state name in a response is safe. A number in a database column, a metric label, or an alert rule from before 1.0.0 now means a different state. An old rule that pages on state 7 pages on a revert.
Advice that is stale in a quieter way
Some statements do not fail with an error. They are wrong, and the system runs.
| Statement | Where you read it | What we found |
|---|---|---|
| Substrate suspends an actor that has no requests. | the quickstart at the tag, and many posts | Nothing suspends it. The caller must call SuspendActor. |
| A suspend returns when the snapshot is safe. | tutorials for v0.1.0, where it was true | The default is asynchronous. The snapshot is on one node first. |
| The agentgateway dataplane accepts any client. | a reading of the manifests | The installer always adds the client certificate check. |
| A credential provider requires the Envoy dataplane. | the help text of the installer flag | The installer accepted the provider with agentgateway, and injection worked. |
Upgrade with NEW_RELEASE=v0.4.2. | the example in the upgrade page at the tag | No v0.4.2 exists, and the same page excludes every 0.x upgrade. |
We measured the first two rows on an x86 server guest (24 vCPU, 47 GB, nested KVM). An actor with no requests for 900 s read RUNNING in 60 of 60 samples. After a default suspend, the snapshots of four actors stayed in the pending state. The upload finished 5 min 1 s to 5 min 11 s after the suspend. Section 3.3 and section 3.4 give both runs in full. Raw runs
Our position: date every Substrate instruction before you follow it. If the text was written before 2026-11-05, check each command against the v1.0.0 help output first. The project made its largest breaks in the three weeks before the release. A post from October can be as wrong as one from July.
Which upgrades the project supports
The upgrade page at the tag gives five cases. The version skew policy that it rests on starts at 1.0.0.
| From and to | Supported | What to do |
|---|---|---|
| A later patch of the same minor, such as 1.0.0 to 1.0.2 | yes, and you can skip patches | Follow the four steps below. |
| The next minor, such as 1.0 to 1.1 | yes | Follow the four steps below. |
| Two minors at once, such as 1.0 to 1.2 | no | Upgrade to 1.1 first. |
| A new major | no | Install again. |
| Any 0.x release to 1.0.0 | no | Install again, with a new database. |
The last row has a hard cause. The project merged all its schema migrations into one file on 2026-10-23, and ate-api-server refuses a schema that has no version. A database from before that date cannot be upgraded. Your actors and their snapshots from a 0.x cluster do not move to 1.0.0.
The order, and what running actors see
Components move in a fixed order. A component is allowed to be one release newer than the ones after it, and never newer than the ones before it. Figure 8.4 shows the four steps.
podcertificate-controller, which signs the certificates that components show each other. Running actors see nothing.atelet, one node at a time. Running actors keep running. In our two runs, suspends and resumes on the node failed for 24 s and for 34 s.- The workers. You change
spec.workerImageof each WorkerPool, and the pool replaces 10 percent of its workers in each batch. ate-api-serverandate-idp-server, then the CRDs andate-controller, then the router and the egress gateway, then the SandboxConfig, then the rest of the system bundle.
Step 3 is the one that costs you work. Each actor on an old worker receives SIGTERM. The upgrade page gives it 30 minutes after that, and our run shows that the time exists only if its process survives the signal. Substrate does not suspend it in that time.
If nothing calls SuspendActor, the actor becomes ACTOR_STATE_CRASHED and loses its work since the last snapshot. This is the idle suspend rule again: your harness must act on the signal. A spot instance gives between 15 seconds and two minutes, never 30 minutes.
Step 4 has one action that cannot be undone. The first new ate-api-server pod migrates the database schema, and a rollback leaves the new schema in place. Take a database backup before step 4. You can roll back to the release you came from and no further.
Our position: before any worker upgrade, stop new requests and wait for the open ones. Then suspend every actor with --mode sync yourself. Keep requests stopped until the workers are replaced, because a request resumes an actor. A synchronous suspend puts the snapshot in the bucket before it returns. Then step 3 finds no running actors, and the 30 minutes do not matter.
Two upgrades that we ran: v1.0.0-rc.3 to v1.0.0
No supported older release exists yet, because the policy starts at 1.0.0. The registry holds images for three release candidates and for v1.0.0, and for nothing older. An install of v0.6.0 from the release images failed with a 404 for its first image. So we installed v1.0.0-rc.3 and moved it to v1.0.0 by the steps above, twice, each time on a new cluster. The policy does not cover this path, and the two releases have the same schema version. Figure 8.4b sets the steps of both runs against the request streams.
We measured the first upgrade on the x86 server guest (24 vCPU, 47 GB, nested KVM). It had one kind node and a pool of 8 workers. Three streams ran during it. One suspended and resumed one actor about every 3.5 s. One listed the actors every 2 s. One sent a request each second to a RUNNING actor. Raw runs
We measured a second upgrade on the same guest, once, on a new cluster with the bundled Postgres and a pool of 10 workers. The streams were the same, and the request stream went to two RUNNING actors. The process of one exits on SIGTERM, and the process of the other handles it. Raw runs
| Step | Seconds, first run | Seconds, second run | What a client saw |
|---|---|---|---|
1, podcertificate-controller | 33.9 | 19.3 | nothing |
2, atelet | 30.9 | 40.7 | 7 suspend and resume cycles in a row failed over 24 s in the first run, and 10 over 34 s in the second. The suspend failed with while getting atelet conn or with Unavailable from atelet, and the request got 503 |
| 3, the worker image of the pool | 0.4 for the patch | 0.3 for the patch | the RUNNING actor that exits on SIGTERM crashed. In the first run it answered 503 from 1.2 s after the patch. In the second it answered 503 from 18 s, and it was ACTOR_STATE_CRASHED at the sample 22 s after the patch |
4a, ate-api-server and ate-idp-server | 28.4 | 45.3 | nothing |
4b, the CRDs and ate-controller | 12.3 | 19.4 | nothing |
| 4c, the router and the egress gateway | 21.8 | 40.7 | nothing |
| 4d, the SandboxConfig | 18.7 | 3.6 | nothing |
| 4e, the system bundle | 29.5 | 36.1 | nothing |
In the first run, step 1 to the end of step 4e took 343 s. That includes 160 s of watching the pool in step 3. The cycle stream had 103 good cycles of 110, and all 7 failures were in step 2. The list stream got an answer 211 of 211 times.
In the second run that time was 346 s, with 135 s of the worker step in it. The list stream got an answer 199 of 199 times. In both runs the schema version did not change, and every Substrate pod ran v1.0.0 at the end.
We made one actor of each kind before each upgrade and called each one after it.
| The actor before the upgrade | First run | Second run |
|---|---|---|
Suspended with --mode sync at count 3 | 200, count 4 | 200, count 4 |
| Suspended in the default mode at count 3 | 200, count 4 | 200, count 4 |
| Suspended at count 5, with a tag | 200, count 6 | 200, count 6 |
| Created and never run | 200, count 1 | 200, count 1 |
| A new actor from the golden snapshot that the release candidate made | 200, count 1 | 200, count 1 |
| A new actor from the tag | 200, count 6 | 200, count 6 |
RUNNING during step 3, with a process that exits on SIGTERM | ACTOR_STATE_CRASHED. 368 of 469 requests got 503. A revert took 0.22 s, and the count went back to 1 | ACTOR_STATE_CRASHED. 118 of 441 requests got 503. A revert took 0.22 s, and the count started again from the golden snapshot |
RUNNING during step 3, with a process that handles SIGTERM | not in this run | 441 of 441 requests served. A suspend of 0.62 s moved it to a new worker, and it kept its count |
| In the suspend and resume loop during step 3 | every cycle of step 3 served | ACTOR_STATE_CRASHED 15 s after the patch, when its suspend failed with Unavailable from atelet. It stayed crashed for 395 s, until our revert. One call was lost |
So snapshots, tags, and the golden snapshot from the release candidate worked on v1.0.0 in both runs. The losses were in step 3. An actor that exits on SIGTERM crashed each time. An actor whose suspend was in progress when its worker went away crashed once, in the second run. Nothing repaired it until we sent a revert.
The 30 minutes depend on the actor
The upgrade page gives each running actor SIGTERM and then 30 minutes. Our running actor did not get 30 minutes. Its process exits on SIGTERM, so the actor crashed in seconds.
We measured this in a second roll of the same pool, now on v1.0.0, once. Two actors ran. The process of the first exits on SIGTERM, and that actor was ACTOR_STATE_CRASHED at the first sample, 5 s after the patch. The process of the second handles SIGTERM and keeps running. That actor stayed RUNNING and answered 36 of 36 requests in 175 s, while its worker pod was Terminating. Raw runs
We then suspended it with --mode sync in 0.64 s. It resumed on a new worker, and its count continued.
The second upgrade and the rollback below each had the same two actors, once. In both, the actor without a handler crashed, and the actor with a handler served every request and kept its count after a suspend. So the result holds in all our rolls of a pool. An actor without a handler crashed in 4 of 4, and an actor with a handler survived in 3 of 3.
So the window exists only for an actor whose main process survives SIGTERM. A program exits on that signal unless it has a handler.
Our runs also move the suspend earlier than the upgrade page puts it. Suspends and resumes failed for 24 s and for 34 s while atelet restarted on our one node. So stop requests and suspend every actor before step 2, and resume traffic after step 3. The actor that crashed in the loop shows the cost of a suspend that is still in progress when the worker goes away.
A rollback that we ran: v1.0.0 to v1.0.0-rc.3
The upgrade page gives one sentence for a rollback. It is the same list in reverse, from the old release, and an actor that crashes goes back to its last snapshot with kubectl ate revert actor.
We measured one rollback, right after the second upgrade, on the same cluster. We ran it from the checkout of v1.0.0-rc.3 with the configuration that we saved before the upgrade, in the reverse order. Before it started, we reverted the actor that had crashed in the loop. The same three streams ran. Raw runs
| Step | Seconds | What a client saw |
|---|---|---|
| 4d, the SandboxConfig | 3.5 | nothing |
| 4c, the router and the egress gateway | 23.3 | nothing |
4b, the CRDs and ate-controller | 5.0 | nothing |
4a, ate-api-server and ate-idp-server | 25.0 | nothing |
| 3, the worker image of the pool, back to v1.0.0-rc.3 | 0.4 for the patch | the actor that exits on SIGTERM answered 503 from 6.4 s after the patch, and 130 of 306 requests failed. A revert took 0.21 s, and its count started again from the golden snapshot. The actor with a handler served 306 of 306 |
2, atelet | 12.7 | one suspend and resume cycle failed |
1, podcertificate-controller | 3.7 | nothing |
We did not run step 4e in reverse. After the rollback every actor returned 200, and each suspended actor kept its count. Every Substrate image was v1.0.0-rc.3, and the schema version had not changed. The list stream got an answer 138 of 138 times.
The rollback cost the same as the upgrade, and in the same step. This pair of releases has no schema change, so our run cannot show what a rollback does to a new schema. The upgrade page says that the new schema stays, and you need the backup from before step 4 for that case.
From v0.6.0: no upgrade, and a reinstall that needs two fixes
The registry has no images for v0.6.0, so we built it from its tag, on a new cluster on the same guest, once. Its README quickstart failed twice. The first deploy stopped at once.
Error: at least one of ATE_INGRESS_ALLOWED_SPIFFE_IDS, config.ingressAllowedSPIFFEIDs or --ingress-allowed-spiffe-ids must be set, got noneWith an ingress identity added, the deploy took 278 s. The README command for the demo, deploy demo-counter, printed the help and returned 0, and deploy demo counter worked. The README then sends a request through port 80 of the router, and the port-forward failed with Service atenet-router does not have a service port 80. So no request reached the four actors that we made on v0.6.0 by the path of the README.
We then ran the v1.0.0 installer on that cluster, as a direct upgrade. It stopped in 3 s.
[step]: deploy_crds
[step]: images: pre-built from ghcr.io/agent-substrate/substrate, tag v1.0.0
…
Error: while applying customresourcedefinition/csidriverconfigs.ate.dev: CustomResourceDefinition.apiextensions.k8s.io "csidriverconfigs.ate.dev" is invalid: status.storedVersions[0]: Invalid value: "v1alpha1": missing from spec.versions …The v0.6.0 system kept running. The v1.0.0 CLI could not talk to it: unknown service substrate.ateapi.v1.Control. Requests through kubectl ate proxy of v1.0.0 still reached three of the four actors, on the old system. The upgrade page of v0.6.0 says to install again, so we did that on the same cluster.
| Step | Result |
|---|---|
ate-setup delete all of v1.0.0 | done in 346 s. It removed every Substrate namespace and volume |
| deploy v1.0.0 | failed in 1 s, on the stored version of the sandboxconfigs.ate.dev CRD. That CRD stayed in deletion, because the SandboxConfig gvisor-default kept the finalizer sandboxconfig.ate.dev/workerpool-protection and its controller was gone |
| remove the finalizer, deploy again | failed after 157 s. atelet stopped with image cache at "/var/lib/ate/image-cache" has layout version "2" and this atelet supports "3" |
| delete that folder on the node, deploy again | done in 30 s |
The new system had no actor and no atespace. Nothing that we made on v0.6.0 survived.
Our position: do not move a 0.x cluster to 1.0. Install 1.0 on a new cluster, create your templates again, and move traffic. If you must reuse the cluster, plan for the finalizer and the image cache, and for an empty system at the end.
What we did not test
We ran two upgrades and one rollback, each once, on one node, and both upgrades started from a release candidate. We did not test an upgrade between two supported releases, a rollback that crosses a schema change, more than one node, or the microVM class. We did not test an upgrade with a Postgres outside the cluster. From v0.6.0 we found no path that keeps an actor.
A new kagent release, v1.0.1, appeared on the day of our kagent run. All our kagent results are from v1.0.0.
Limits at v1.0.0
At v1.0.0 Substrate runs, suspends, and resumes actors on request, and it leaves the suspend decision, pool size, failover, and key rotation to you.
You plan a rollout and write down what the platform will do for you. It will stop idle sessions. It will add capacity under load. It will survive the loss of one pod. For Agent Substrate v1.0.0, each of those three lines is wrong, and the release does not say so in one place.
When you finish this section, you can list what Substrate does not do at v1.0.0. You can also say which of our claims rest on a run, and which parts we did not test.
What Substrate leaves to you
Figure 8.5 sets each job that a platform team expects against the party that does it at v1.0.0.
No idle suspend
Nothing in Substrate suspends an actor because it has no requests. The code has no idle timer in ate-api-server, in atelet, or in either worker. The worker API has a method for a worker to ask for a suspend, RequestActorSuspend. atelet forwards that call, and a client library for it exists in the repository. No worker at v1.0.0 uses the library, so nothing sends the call.
We measured this on an x86 server guest (24 vCPU, 47 GB, nested KVM). One actor received no request for 900 s, and it read RUNNING in 60 of 60 samples. A running actor reserves its full CPU and memory limit on a worker for all that time. Raw runs
The harness must call SuspendActor. kagent does it after every turn, in 98 of 98 turns in our run, as section 7.1 shows. If you write your own harness, the suspend call is the first thing to build.
No autoscaler, and no overcommit
A WorkerPool has a fixed replicas number. Nothing in the install changes it. The WorkerPool kind has a scale subresource, so an outside autoscaler can change the number, and we did not test one. CPU and memory use are poor signals for it. An actor reserves its limit while it runs, whether or not it works.
Substrate also books each running actor at its full declared limit. A proposal to book less than the limit exists in the public issues, and the maintainers moved it to after the release.
We measured the effect in the kagent run, on a second x86 server guest (16 vCPU, 31 GB). One run started 24 turns at the same time. The single worker reported 16,000 millicores, and each actor reserved 1,000. The worker stopped at 16 running actors, and the other eight turns waited. Raw runs
When a pool is full, a request waits up to 5 s and then gets a 503. Section 4.5 gives the arithmetic for a pool.
No GPU
An ActorTemplate at v1.0.0 can declare two resources: cpuMillicores and memoryBytes. The API has no field for a GPU or for any other device, so we ran no GPU test.
Single-replica components
The code sets these replica counts in the install manifests.
| Component | Replicas | What stops when the one pod is away |
|---|---|---|
ate-api-server | 2 | nothing, while one replica remains |
ate-idp-server | 2 | nothing, while one replica remains |
atenet-router | 1 | every request to every actor |
atenet-egress | 1 | every new outbound connection from an actor |
ate-controller | 1 | changes to worker pools |
podcertificate-controller | 1 | new and renewed certificates |
| bundled Postgres | 1 | every request, as section 8.2 measures |
The right column is our reading of the request path, except for the Postgres row, which we ran. The router keeps its parked requests in memory, and a graceful shutdown does not drop them. After the stop signal the router keeps the parked requests until each one gets its full budget and a normal answer. The flag --drain-timeout sets that limit, and its default is longer than the parking budget. Only a crash, a forced kill, or a drain past --drain-timeout drops a parked request.
We restarted the router once, as the fix in section 8.2. We did not measure what a restart does to parked requests, and we did not restart the egress gateway.
Crash detection has a gap
Substrate marks an actor crashed when its sandbox dies or when the kernel kills it for memory. It does not notice a process that exits inside a sandbox that still runs. Section 8.3 shows that actor. A design for this detection is open in the public issues.
We measured two such actors on the x86 server guest (24 vCPU, 47 GB, nested KVM). Both read RUNNING for 20 minutes 36 s after the exit, in 81 and 80 samples. All 20 requests to one of them returned 503. Each kept 1,000 millicores of its worker for all that time. The first suspend call failed and marked the actor crashed. We did not watch longer than 20 minutes, and we did not restart the worker. Raw runs
It also does not mark a running actor whose node stops. On a cluster with two worker nodes, that actor read RUNNING for the 10 minutes of our probe. Each of 150 requests got a 503.
An actor does not leave a dead node by itself
A suspended actor is bound to the node of its last suspend, and Substrate v1.0.0 did not release it when that node stopped.
We measured this on the second x86 server guest (16 vCPU, 23 GB), with two worker nodes and one actor for each of four cases. An actor had a complete snapshot in the object store. It stayed in RESUMING on the stopped node for 5 min 33 s from its first request. It was crashed 5 min 44 s after the stop command. The other node had two free workers. A revert during the outage failed or had no lasting effect in 4 of 4 attempts on three actors. Raw runs
The workers of a stopped node were listed as active for 43 to 51 s after the stop command. A new resume can go to a dead node in that time. Kubernetes moved the worker pods to the other node after 5 minutes and did not move them back. Section 4.3 has the timeline.
We measured 13 more node-stop cases on a second x86 server guest (16 vCPU, 23 GB), once each, with the default timers. In none of them did an actor leave the stopped node by itself. One revert freed an actor in one situation only: suspended with --mode sync, no request since the stop, and the workers already marked unavailable. That revert took 7.1 s. In every other situation a revert left the actor in REVERTING until the node was back. A new actor from a tag that existed before the stop served its first request after 1.2 s, in 3 of 3 rescues. Raw runs
So recovery from a node loss is yours to build. Substrate does not move actors, and it does not move pool pods back to a node that returns. Section 8.2 has the runbook.
Access is coarse at the router
The ingress allowlist holds client identities, and each listed client can reach every actor in every atespace. Access policies for each atespace govern the API calls, as section 5.2 explains. They do not govern requests through the router at v1.0.0.
Manual key rotation
The key that signs actor JWTs lives in a pool that an operator edits. The CLI has kubectl ate admin add-jwt-key, activate-jwt-key, and remove-jwt-key, and the documentation gives the waits between them. No component rotates the key on a schedule. Section 5.3 covers the tokens. Our install had one key, and our decoded token named it. We did not run a rotation.
A suspend does not wait for requests
SuspendActor stops the sandbox at once. A request that is open at that moment gets a 503, and the suspend call still reports success. We saw this in both suspend modes, as section 3.3 shows. The caller of the suspend must know that the actor is idle.
Preview features are off
Three features exist behind a server flag and a client flag. All three are off by default.
| Preview name | What it adds |
|---|---|
ExternalVolumes | CSI volumes that an actor mounts and that no snapshot includes |
GoldenEgressPolicy | an egress policy for the actor that builds the golden snapshot |
PerTemplateSnapshotLocation | a snapshot location in each template, in place of one for the cluster |
To use one, start every component with --preview and its name, and pass --preview to kubectl ate. A request without the client flag behaves as if the feature were off. We tried all four combinations with a template that has a goldenEgressPolicy field.
Server --preview | kubectl ate --preview | Result of create actor-template |
|---|---|---|
| empty | no | InvalidArgument desc = actor_template.golden_egress_policy: Forbidden |
| empty | yes | the same error |
GoldenEgressPolicy | no | the same error |
GoldenEgressPolicy | yes | accepted, and the golden tag was ready within 30 s |
2026-11-12T13:06:36.842Z on no-flag Error: failed to create actor template: rpc error: code = InvalidArgument desc = actor_template.golden_egress_policy: Forbidden We ran each combination once on the x86 server guest. The error does not name the preview feature or the flag, so you must know that the field is a preview field. Once the template existed, kubectl ate get read it without the client flag.
Our test had two limits. We set the flag on ate-api-server only, with an edit of its Deployment, and the documented way sets it on every component through ate-setup. We also did not test that the egress rule of the golden actor works. Every other experiment ran with --preview= empty, so this manual describes none of the three features in use.
Smaller limits that will reach you
- An open response or pass-through connection outlives its egress rule. Removing a rule stops requests, also on a reused connection, within 9 s. Only a suspend cuts the rest, as section 6.6 shows.
- A changed Secret takes up to 5 minutes to reach requests on agentgateway. We measured 100.6 to 280.5 s there, and 1.0 to 1.6 s on Envoy. Section 6.5 has the run.
- The egress gateway trusts public roots only. A host with a certificate from a private CA returned 503 until we replaced the root file in the proxy container. The installer removes that patch. Section 6.4 has it.
- On agentgateway, the stats port 15020 of the router answers
/metricswith no credentials, and the egress gateway logs atdebugafter every installer run. Section 6.1 and section 6.6 have both. kubectl ate delete tagaccepts the golden tag of a template. The template then lists a tag that is gone, andcreate actorfails withTag not found. Section 3.4 has that mistake.- An actor sees 2 CPUs at every CPU limit, and the limit is a hard cap. Section 4.1 has the measurement.
- Names are identities. The documentation tells you never to reuse the name of a deleted atespace. A request that was allowed for the old one can apply to the new one.
- Postgres is the only store. The maintainers declined a storage plugin before the release.
- A durable directory has no size limit in the API, so one actor can fill the node disk. This comes from an open issue. We did not test it.
- The gVisor class accepts a limit for each container and enforces only the limit of the actor.
Our position: none of these limits makes v1.0.0 unusable. Together they mean that Substrate is one layer of a platform. Budget engineering time for four things. They are a harness that suspends, an autoscaler that reads free worker slots, a database with failover, and a probe for dead processes. Section 7.4 weighs that cost against the alternatives.
What we did not test
Most measured numbers in this manual came from clusters with one kind node. One cluster had one control plane node and two worker nodes. Every cluster had one of two dataplanes. The object store was in the cluster, except in two experiments. In one it ran outside the cluster on the same guest, and in the other on a second guest of the same server. This is the full list of what we did not run.
| Area | Not tested |
|---|---|
| Clouds | GKE, and every other managed Kubernetes service |
| Storage | Google Cloud Storage, Amazon S3, a real link with 100 ms of delay to a bucket, more than one cycle of 1,000 MiB with 100 ms on the path between two guests, a managed Postgres, a Postgres outage with dropped packets, the cause of one running actor that stayed at 504 after a Postgres outage, a connection pooler, a restore of either store |
| Scale | more than 10,750 actor rows in one cluster, more than 450 actors with a snapshot of their own, more than 300 resumes at one time, more than 300 workers on one node, a burst on more than one node, a burst to actors whose snapshot is only in the bucket, what inside the gVisor restores sets about 37 resumes each second, more replicas of ate-api-server with a higher connection limit of Postgres, a third run of 200 and of 300 at one time, more than 24 parallel turns, more than two worker nodes, more than 20 actors across nodes, and the site claims of millions of agents and of 10x density |
| Sandbox | the microVM class on arm64, with agentgateway, with a resume from the object store, above 500 MiB, and across CPU models. Also images with an init system, non-root images, large root filesystems |
| Agents | a hosted model, a model on a GPU, or a model large enough for real agent work behind kagent, any real model behind a model gateway, a kagent turn that runs away while a bound is set, the maxCompletionTokens field of a ModelConfig, a streamed model reply through a model gateway, client authentication at a model gateway, the codex and claude harnesses, MCP servers through the egress gateway |
| Lifecycle | an upgrade from v0.6.0 that keeps actors, an upgrade between two supported releases, a rollback across a schema change, an upgrade on more than one node or with the microVM class, a third run of an upgrade step, a drain with a PodDisruptionBudget on the pool, a drain of the node that runs ate-system, a revert in the first 20 s after a drain, the restore of a deleted golden tag, a resume at the two lower fidelities for an application with a long start on this guest |
| Network | a suspend as a cut-off for a tlsPassthrough connection, the step that takes 0.6 s in an intercepted handshake, the log level of atenet-router, a restart of atenet-egress as a way to refresh a credential, the content of every metric label on the agentgateway stats port, port 9902 of the Envoy container |
| Limits | memory limits under load, and CPU limits with several busy actors on one worker |
| Identity | the signature check of an actor JWT with the published key, a key rotation, federation with a second issuer or a cloud provider, a second token issuer for clients |
| Snapshots | why the object store copy of the Python actor was half of its node copy, and equal for random bytes |
| Failure | a dead process that stays RUNNING for more than 20 minutes or across a worker restart, the three outages on more than one node, egress during an outage, parked requests during a router restart, an egress restart, the cause of a burst request with no answer in 90 s, the cause of an actor left in ACTOR_STATE_RESUMING after a database outage, a node that is alive and unreachable, a node that loses power or its network, a node loss on the microVM class or on agentgateway, a node that is full by CPU or by a count of actors, a second run of the Kubernetes API outage with resumes and free workers, a pool with spare workers on a third node, other node timers of Kubernetes |
| Agent Executor | a hosted or large model in a task, the example agent with a model that can do its work, a node loss under a task, a large or private clone, an MCP server that the image starts as a command, an MCP registry with listed servers, an MCP server outside the cluster, the release v0.3.1 on the Substrate commit that it names |
| Apple silicon | actors above 200 MiB, a resume from the object store |
| Preview | ExternalVolumes and PerTemplateSnapshotLocation on upstream Substrate, the flag on every component, and the behaviour of a golden egress policy |
Where a section states a fact from these areas, it comes from the code or the documentation at the tag, and the text says so.
Build
Four small programs that you can run show what you must build around Substrate: an idle suspender, a stateful agent, a tool sandbox, and a failure drill.
An idle suspender
A small proxy that records the last request for each actor and calls SuspendActor after 30 quiet seconds ran three actors in room for two.
You installed Substrate because most of your agents wait most of the time. Then you watch the pool, and every actor that ever got a request is still running. Nothing suspended them, because nothing in Substrate v1.0.0 does that. The missing piece is yours to write, and it is small.
When you finish this section, you can run a program that suspends idle actors. You can also say what signal it uses, what it costs each client, and where the design stops.
Which signal says that an actor is idle
Idle suspender: a program outside Substrate that decides when an actor has had no work for long enough, and calls SuspendActor for it.
The hard part is the signal. We looked for one on the server side and did not find one that fits.
| Candidate signal | Where it is | Why it does not fit |
|---|---|---|
| request metrics of the router | atenet.router.route.duration | Actor names are not allowed as metric labels, so it cannot say which actor is quiet |
| the usage event of each actor | ate.actor.usage_sampled | It reports CPU time and memory once a minute, and it measures work, not requests |
| the actor record | kubectl ate get actor | It has a state and an update time, and no time of the last request |
So the suspender has to see the requests itself. It sits in the path of the traffic, and the time of the last request is a value in its own memory.
The program
The demo is one Python file of 125 lines with no dependencies. It starts one kubectl ate proxy actor for each actor that you name. It listens on one port and forwards /<actor>/<path> to the proxy of that actor.
python3 suspender.py --atespace demo-idle --idle 30 --listen 8080 busy bursty rare
curl http://127.0.0.1:8080/busy/One loop makes the decision once a second. An actor is due when it is running, has no open request, and finished its last request at least --idle seconds ago.
due = [name for name, actor in actors.items()
if actor["running"] and actor["open"] == 0 and now - actor["last"] >= args.idle]
for name in due:
done, seconds = cli("suspend", "actor", name)A request that arrives during a suspend waits until the suspend ends. The suspender never resumes an actor. The next request goes to the router as before, and the router resumes the actor. The full file is in the idle suspender folder.
Ten minutes with three actors and room for two
The pool in the recording has one worker with two CPUs, and each actor asks for one CPU. So two actors can run at one time. We created three, and sent each a different pattern of traffic.
busygets one request each 5 seconds.burstygets five requests in 5 seconds, and then nothing for 145 seconds.raregets one request after 60 seconds and one after 400 seconds.
2026-11-12T12:50:05 watch busy port=18080 running=False
2026-11-12T12:50:05 watch bursty port=18081 running=False
2026-11-12T12:50:06 watch rare port=18082 running=False
2026-11-12T12:50:09 listen http://127.0.0.1:8080/<actor>/<path> idle=30s
2026-11-12T12:50:13 request bursty 200 0.443 resumed
2026-11-12T12:50:13 request busy 200 0.487 resumed
2026-11-12T12:50:14 request bursty 200 0.063 warm
…
2026-11-12T12:50:44 request busy 200 0.069 warm
2026-11-12T12:50:49 suspend bursty idle=31s 0.386 ok
2026-11-12T12:50:49 request busy 200 0.063 warm
…
2026-11-12T12:51:09 request busy 200 0.065 warm
2026-11-12T12:51:13 request rare 200 0.242 resumed
2026-11-12T12:51:15 request busy 200 0.062 warm
…
2026-11-12T12:51:40 request busy 200 0.058 warm
2026-11-12T12:51:44 suspend rare idle=31s 0.349 ok
2026-11-12T12:51:45 request busy 200 0.065 warmRead the fifth field of each request line. The word resumed marks a request that found the actor suspended. At 12:50:49 the suspender suspended bursty after 31 quiet seconds. That freed one of the two places. At 12:51:13 the first request for rare arrived, and the router resumed it into the free place in 0.24 seconds.
We measured this run on a second x86 server guest (16 vCPU, 31 GB), with the suspender on the same guest as the cluster. In 10 minutes the three actors got 140 requests, and all 140 returned 200. Raw runs
| Result | Value |
|---|---|
| requests that found the actor suspended | 7 of 140 |
| time of those requests, after the first two | 0.24 to 0.26 s |
| time of a request to a running actor, median | 0.06 s |
| suspends by the suspender | 7, each 0.33 to 0.39 s at the CLI. Six came in the 10 minutes, and the seventh came 10 min 34 s after the start |
| CPU booked on the worker, mean of 58 samples | 1,345 of 2,000 millicores |
The first two resumed requests took 0.44 and 0.49 seconds, because two actors started in the same second. A client that meets a suspended actor pays about 0.2 seconds more than a warm request for this small actor. SuspendActor and ResumeActor shows how that cost grows with memory.
The same traffic without the suspender
We ran the first two minutes again with the suspender in --dry-run mode. It forwards requests and logs each decision, and it suspends nothing.
ts actor http seconds body
…
2026-11-12T13:05:30.579Z rare 503 5.054876 actor demo-idle/rare unavailable: no worker has room for the actorAfter 60 seconds both places were still taken by busy and bursty, and bursty had been quiet for 55 seconds. The request for rare waited the 5 second parking budget and returned 503. Request parking explains that wait.
Limits of the design
- It sees only its own traffic. A request that reaches the router another way does not reset the timer. Send all traffic through the suspender, or the suspender stops an actor that is in use.
- Idle means no request, not no work. An actor that computes after it answers looks idle. The usage events can cover that case, at a delay of one minute.
- One process holds the state. A restart forgets the last request times. The demo then reads the state of each actor and starts its timers again.
- A request can arrive during a suspend. The demo holds a new request until its suspend call ends, and then forwards it. Your own suspender needs the same rule for each actor. A request that reaches the router another way still gets a 503.
- The demo uses the CLI. Each suspend starts one process and uses the shared identity of the CLI.
What to change for production
Run the suspender inside the cluster, beside the router. Give it a client certificate with an identity on the ingress allow list, and let it call ate-api-server with its own identity. Then it no longer needs the Kubernetes API server for each request.
Choose the idle time from the resume cost of your actor. With a resume of 0.25 seconds, 30 seconds of idle time is safe. With a resume of 5 seconds, a short idle time makes many users wait, and a long one keeps workers full.
Suspend in the default asynchronous mode, as the demo does. Then read Snapshot fidelity, and where a snapshot lives for what a node loss costs in the first 5 minutes.
A stateful agent actor
An agent that writes each turn to a durable directory keeps its conversation under both snapshot fidelities, and the fidelity decides what else it keeps and how fast it returns.
You have an agent that talks to a model and keeps a conversation. You want to run one copy for each user, and you do not want to pay for a pod while the user reads the answer. The question that decides your design is simple. What does the agent still know after a suspend?
When you finish this section, you can build an agent actor that keeps its conversation across a suspend. You can also choose a snapshot fidelity from a measured cost, and give the agent a model key that it never holds.
The agent
The demo agent is one Python file of 100 lines with no dependencies. It serves three paths, and it handles one chat turn at a time.
POST /chatadds your message to the conversation, calls the model, and stores both messages.POST /notekeeps a text in a Python variable and in a file under/tmp. Nothing durable holds it.GET /historyreturns the conversation and five facts about the state of the process.
The agent keeps the conversation in two places. One is a list in process memory. The other is a SQLite file in /data, which the template mounts as a durable directory. When the process starts, it loads the list from the file.
def stored_turns():
with database() as connection:
return [{"role": role, "content": content}
for role, content in connection.execute("select role, content from turns order by n")]
memory = stored_turns()
rehydrated = len(memory)That one load at the start is the whole design. A process that restarts with an empty memory reads its past from the durable directory. The full file is in the stateful agent folder.
The model key
The agent sends the text Bearer placeholder in its Authorization header. It has no other key. The real key is a Kubernetes Secret in a namespace that the actor cannot read. The egress gateway replaces the header on the way out.
…
rules:
- http:
effects:
replaceHeaders:
- credentialUri: ate-secret://k8s.io/default/demo-agent-secrets/model-key/token
header: Authorization
prefix: 'Bearer '
hostnames:
- mock-llm.demo-agent.svc.cluster.local
ports:
numbers:
- 80The model in this demo is a mock server in the cluster, not a real model. It answers from the messages that it receives, and it reports which key arrived. In every one of the 16 model calls of our two sessions it reported the real key. Credential injection explains the mechanism and its limits.
Two sessions, one for each fidelity
We made the same template two times. One copy asks for SNAPSHOT_FIDELITY_MEMORY and the other for SNAPSHOT_FIDELITY_VOLUMES. Each session has two turns and a note, then three rounds of suspend, one turn, and one more turn.
step http seconds reply
turn 1, new actor 200 0.584397 {"reply": "turn 1: you said 'my name is Ada'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 2, "db_turns": 2, "rehydrated_from_db": 0, "note": null, "scratch_file": false, "proces …
note, memory only 200 0.072225 {"memory_turns": 2, "db_turns": 2, "rehydrated_from_db": 0, "note": "remember the number 42", "scratch_file": true, "process_age_s": 10.8}
turn 2, warm 200 0.112794 {"reply": "turn 2: you said 'what is my name'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 4, "db_turns": 4, "rehydrated_from_db": 0, "note": "remember the number 42", "scratch_file" …
turn 3, after suspend 1 200 0.297402 {"reply": "turn 3: you said 'message after suspend 1'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 6, "db_turns": 6, "rehydrated_from_db": 0, "note": "remember the number …
turn 3b, warm 200 0.101483 {"reply": "turn 4: you said 'warm message 1'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 8, "db_turns": 8, "rehydrated_from_db": 0, "note": "remember the number 42", "scratch_file" …Under memory fidelity the agent is the same process after the suspend. The note is there, the file under /tmp is there, and process_age_s keeps growing. The agent loaded nothing from SQLite, because it never started again.
step http seconds reply
turn 1, new actor 200 5.083124 {"reply": "turn 1: you said 'my name is Ada'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 2, "db_turns": 2, "rehydrated_from_db": 0, "note": null, "scratch_file": false, "proces …
note, memory only 200 0.068069 {"memory_turns": 2, "db_turns": 2, "rehydrated_from_db": 0, "note": "remember the number 42", "scratch_file": true, "process_age_s": 0.4}
turn 2, warm 200 0.096986 {"reply": "turn 2: you said 'what is my name'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 4, "db_turns": 4, "rehydrated_from_db": 0, "note": "remember the number 42", "scratch_file" …
turn 3, after suspend 1 200 5.059144 {"reply": "turn 3: you said 'message after suspend 1'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 6, "db_turns": 6, "rehydrated_from_db": 4, "note": null, "scratch_file": …
turn 3b, warm 200 0.093130 {"reply": "turn 4: you said 'warm message 1'. Your first message was 'my name is Ada'. Gateway sent the real key.", "memory_turns": 8, "db_turns": 8, "rehydrated_from_db": 4, "note": null, "scratch_file": false, "process_ag …Under volumes fidelity the agent is a new process after each suspend. Its age is 0.3 seconds when it answers. The note and the file are gone. The conversation is complete, because the new process loaded four turns from SQLite. The model still received the first message of the session in each call.
What each fidelity costs
We measured the time of each turn at the client, on a second x86 server guest (16 vCPU, 31 GB), through kubectl ate proxy. Each session has three turns that follow a suspend and four turns on a running actor. The mock model answers at once, so these times hold no model time. Raw runs
| Turn | Memory fidelity | Volumes fidelity |
|---|---|---|
| on a running actor | 0.09 to 0.11 s | 0.09 to 0.10 s |
| after a suspend | 0.30 to 0.32 s | 4.49 to 5.06 s |
| first turn of a new actor | 0.58 s | 5.08 s |
The lifecycle log of the actor shows where the time goes. Under memory fidelity the restore took 0.15 to 0.17 seconds. Under volumes fidelity it took 4.3 to 4.8 seconds, because the sandbox boots the image and starts Python each time. We did not measure which part of that boot is the largest.
This result differs from the kagent run in kagent 1.0 on Substrate, where a resume under volumes fidelity took 0.38 seconds. The kagent harness is a compiled program in another image. Your own image decides this number, so measure it before you choose.
Limits of the demo
- The model is a mock. A real model adds its own time to each turn.
- One actor ran in each session. We did not measure many agents that resume at the same time. Sizing a pool has bursts of up to 300 counter actors.
- SQLite holds each turn after the model answers. A crash between the answer and the write loses that turn.
- The agent has no authentication. Each caller that can reach the router can read the conversation.
What to change for production
Keep the write to the durable directory even when you choose memory fidelity. A crashed actor returns to its last durable snapshot, and a revert can drop the newest memory. The durable directory is part of that snapshot, so the file goes back with it. A turn is safe only after the next synchronous suspend, or in a store outside the actor.
Choose memory fidelity when a user waits for the first answer after a pause, and your agent holds little memory. Choose volumes fidelity when the agent holds much memory or when you want a clean process after each suspend. For a clean process on every turn, the harness must suspend the actor between turns. Then make the image start fast.
A tool sandbox
An actor with one egress rule, two resource limits, and a revert after each task runs commands that you do not trust, and returns clean in half a second.
Your agent writes a shell command, and something must run it. If that command runs beside your application, one bad line of model output can read your secrets or send them away. You want a place where the command can do its work and nothing else.
When you finish this section, you can run commands that you do not trust inside an actor. You can also prove what the actor can reach, and return it to a clean state after each task.
The sandbox actor
Tool sandbox: an actor whose only job is to run commands for an agent. It has no credential, no route out except the ones you list, and no state that you keep.
The demo actor is 38 lines of Python. It accepts a command, runs it with sh -c in the directory /work, and returns the exit code and the output.
task = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
done = subprocess.run(["sh", "-c", task["cmd"]], cwd=WORK,
capture_output=True, text=True, timeout=task.get("timeout", 20))
result = {"exit": done.returncode, "stdout": done.stdout[-2000:], "stderr": done.stderr[-1000:]}Three things around that code make it a sandbox, and Substrate supplies all three. The full files are in the tool sandbox folder.
The first is the egress policy. The actor has one rule, for one host, and Substrate denies every other destination. In this demo the host is a package mirror inside the cluster.
…
rules:
- http:
hostnames:
- pkg-mirror.demo-tools.svc.cluster.local
ports:
numbers:
- 80The second is the pair of limits in the template: 500 millicores and 256 MiB. The third is the golden snapshot of the template. A new actor starts from it.
RevertActor returns an actor to its latest durable snapshot. That snapshot is the golden snapshot only while the actor has never had a suspend of its own made durable. This demo never suspends the actor, so each revert here returns the golden state. An actor that something suspends, such as an idle suspender, gets its own durable snapshot, and a later revert returns to that one.
kubectl ate create actor task-1 -a demo-tools --template sandbox
kubectl ate create egress-policy task-1 -a demo-tools -f egress-policy.yaml
kubectl ate proxy actor task-1 -a demo-tools 8901:80 &
python3 client.py 8901 'wget -q -O hello.txt http://pkg-mirror.demo-tools.svc.cluster.local/hello-1.0.txt'
kubectl ate revert actor task-1 -a demo-toolsSix tasks
We sent six commands to one actor. After each command the script reverted the actor, and then asked it how many files /work holds.
label phase state http seconds result
allowed download task RUNNING 200 0.54 {"exit": 0, "stdout": "hello package 1.0\nhello.txt\n", "stderr": "", "seconds": 0.17}
allowed download revert SUSPENDED - 0.47 -
allowed download clean-check RUNNING 200 0.44 {"exit": 0, "stdout": "0\n", "stderr": "", "seconds": 0.16}The download worked, and the command saw the file hello.txt. The revert took 0.47 seconds. The next request resumed the actor from the golden snapshot in 0.44 seconds, and /work held no file.
The next three commands try to reach a destination that the policy does not list.
send data out by name task RUNNING 200 0.20 {"exit": 0, "stdout": "** server can't find example.com: NXDOMAIN\n\nwget_exit=1\n", "stderr": "wget: bad address 'example.com'\n", "seconds": 0.11}
…
send data out by IP task RUNNING 200 0.15 {"exit": 0, "stdout": "wget_exit=1\n", "stderr": "wget: server returned error: HTTP/1.1 403 Forbidden\n", "seconds": 0.04}
…
reach the Kubernetes API task RUNNING 200 0.13 {"exit": 0, "stdout": "wget_exit=1\n", "stderr": "wget: bad address 'kubernetes.default.svc.cluster.local'\n", "seconds": 0.04}A name outside the policy does not resolve. The command gets NXDOMAIN, and wget reports a bad address. That is also true for the Service name of the Kubernetes API. A request to an IP address reaches the egress gateway, and the gateway answers 403. The command had no way to learn if the address exists.
The last two commands test the limits.
2 s of CPU work on a 500m limit task RUNNING 200 2.63 {"exit": 0, "stdout": "wall_seconds 2.38\n", "stderr": "", "seconds": 2.54}
…
memory hog on a 256 MiB limit task CRASHED 503 4.52 upstream connect error or disconnect/reset before headers. reset reason: connection termination
memory hog on a 256 MiB limit revert SUSPENDED - 0.43 -
memory hog on a 256 MiB limit clean-check RUNNING 200 0.44 {"exit": 0, "stdout": "0\n", "stderr": "", "seconds": 0.11}The memory limit works as a hard stop. A command that allocated 640 MiB ended the whole sandbox. The client got a 503 after 4.52 seconds, the actor was CRASHED, and one revert returned a clean actor.
The CPU line of this demo does not show what it seems to show. The command runs a loop until the process has used 2.0 seconds of CPU time, and it reads that time inside the sandbox. It finished in 2.38 seconds on a limit of 500 millicores. Our first reading was that the limit did not slow the command. That reading was wrong, and a later test supersedes it.
The CPU limit is a hard cap. The worker writes a CPU quota from cpuMillicores into a cgroup for the actor. For this template the quota is 50 ms of CPU time in each 100 ms period. CPU time that a program reads inside a throttled sandbox is too high. So the loop of the demo stopped early, after less real work than 2.0 seconds of one core.
We measured the cap on a second x86 server guest (16 vCPU, 23 GB), with fixed work and three runs for each limit. Work that took 4.9 to 5.4 seconds at 1000 millicores took 12.7 to 13.1 seconds at 500 and 27.1 to 27.6 seconds at 250. The actor used 0.50 host cores at 500 millicores, with 1 thread and with 4. At 250 millicores the program reported 22 to 24 seconds of CPU time for a run that used 6.9 seconds on the host. WorkerPool has the table and the chart. Raw runs
For a tool sandbox this is the result that you want. A busy command cannot take more CPU than its limit, and it cannot starve the next actor on the worker. Measure work in a sandbox with a fixed task and a wall clock, and never with the CPU time that the task reports.
What a revert costs
We measured six reverts on a second x86 server guest (16 vCPU, 31 GB). Each took 0.43 to 0.51 seconds at the CLI. The first request after each revert took 0.44 to 0.46 seconds, which includes the resume from the golden snapshot. A clean sandbox for the next task costs about one second in total. Raw runs
That number depends on the golden snapshot. This actor holds a Python process and no data. SuspendActor and ResumeActor shows how the time grows with memory.
Limits of the demo
- The actor has no authentication. A caller that can reach it can run commands. Put it behind your agent, and give the router a strict allow list.
- We did not run a command that starts processes without end.
- The mirror is one in-cluster server over HTTP. A public package index needs a
tlsPassthroughrule, which needs no trust bundle. Use anhttpsrule and the trust bundle only if the gateway must read the request or inject a credential. - gVisor is the isolation here. We did not run this demo on the microVM class.
- A command can fill
/workand the root file system up to the memory and disk that the worker allows. We did not test a disk limit.
What to change for production
Use one actor for each task or each user, and delete it at the end. Only a new actor is certain to start from the golden snapshot. A revert returns to the latest durable snapshot of the actor, and a suspend during a task can make the state of that task durable. If anything in your system can suspend a sandbox actor, delete it and create a new one after each task. A new name also leaves no doubt about old state. We did not time a revert against a delete and a create.
Write the egress policy before the first command runs. An actor with no policy can reach nothing, which is the safe default. List package hosts by full name, and never add a rule for a host that also holds your data.
A failure drill
One script causes six failures on a test cluster and prints the symptom, the state, the fix, and the recovery time for each.
Your first crashed actor will arrive at a bad time. A user reports a 503, and you read error texts that you have never seen before. You can see every one of those texts today, on a cluster that nobody depends on, in about one minute.
When you finish this section, you can run the six failures of this manual on your own cluster. You can also compare your recovery times with ours and know which fix belongs to which symptom.
What the drill does
Failure drill: a script that causes each known failure on purpose, on a cluster you can lose, and records what a client sees.
The drill uses one small actor. It counts requests, and it has two extra paths. A request to /exit ends the process. A request to /oom allocates memory until the sandbox stops it. The pool has two workers with room for one actor each, so the pool is easy to fill.
For each case the script prints the same five facts. The symptom is the status code, the time, and the body of the first failed request. The state is what kubectl ate get actor reports. The fix is the command that we ran. The recovery line is the first request that returned 200 after the fix.
export REGISTRY=localhost:5001
export KIND_NODE=kind-control-plane
./drill.sh setup
./drill.sh run | tee my-drill.logThe script is about 200 lines of shell, and the actor is 45 lines of Python. Both are in the failure drill folder. The core of each case is short. This is the first one.
actor d1 8701
call 8701 / > /dev/null
pod=$(K get actor d1 -a "$SPACE" | awk 'NR == 2 { print $5 }' | cut -d/ -f2)
kubectl -n "$SPACE" delete pod "$pod" --grace-period=0 --force
report detect "CRASHED after $(wait_state d1 ACTOR_STATE_CRASHED)"
report symptom "$(call 8701 /)"
K revert actor d1 -a "$SPACE"
report recover "$(call 8701 /)"The recorded run
We measured one full run on a second x86 server guest (16 vCPU, 31 GB). Substrate came from the release images, with the Envoy dataplane. The run took 70 seconds for the six cases. Raw runs
Three cases end in ACTOR_STATE_CRASHED without help, and one command repairs each of them.
== 1 worker pod deleted under a running actor
detect CRASHED after 0.2 s
symptom 503 in 0.073744 s: actor demo-drill/d1 unavailable: AssignWorker prerequisite not met for Actor: demo-dril …
state ACTOR_STATE_CRASHED
fix kubectl ate revert actor d1 -a demo-drill (0.2 s)
recover 200 in 0.327434 s: count=1 process_age_s=27.9
== 2 node-local snapshot removed before the upload
symptom 500 in 0.078639 s: error resuming actor demo-drill/d2
state ACTOR_STATE_CRASHED
fix kubectl ate revert actor d2 -a demo-drill (0.2 s)
recover 200 in 0.413543 s: count=4 process_age_s=32.3 (durable snapshot held count=3)
…
== 4 the actor uses more memory than its limit
detect CRASHED after 2.9 s
symptom 503 in 0.062056 s: actor demo-drill/d4 unavailable: AssignWorker prerequisite not met for Actor: demo-dril …
state ACTOR_STATE_CRASHED
fix kubectl ate revert actor d4 -a demo-drill (0.2 s)
recover 200 in 0.364615 s: count=1 process_age_s=47.3 (new actor from the golden snapshot)Read the three recovery lines with care, because each actor returns at a different point. The actor of case 1 had no snapshot of its own, so it restarts from the golden snapshot with a count of 1. The actor of case 2 had a durable snapshot at a count of 3, and it answers 4. Calls 4 and 5 from before the failure are gone. CRASHED and RevertActor explains that rule.
The third case is the one to practise. The process inside the sandbox ends, and Substrate does not see it.
== 3 the actor process exits inside the sandbox
symptom 503 in 0.055202 s: upstream connect error or disconnect/reset before headers. reset reason: connection ter …
state ACTOR_STATE_RUNNING (5 s after the exit)
detect suspend fails, state ACTOR_STATE_CRASHED
fix kubectl ate suspend actor d3, then kubectl ate revert actor d3 (0.7 s)
recover 200 in 0.408503 s: count=2 process_age_s=41.6 (durable snapshot held count=1)Five seconds after the exit, the state is still ACTOR_STATE_RUNNING, and each request returns a 503 from the proxy. The drill calls SuspendActor to make Substrate look. That call fails, the state changes to crashed, and the revert then works. The two commands took 0.7 seconds together.
The last two cases are not crashes. No actor needs a revert.
== 5 more running actors than the pool has room for
symptom 503 in 5.050146 s: actor demo-drill/d5c unavailable: no worker has room for the actor
state d5a ACTOR_STATE_RUNNING, d5b ACTOR_STATE_RUNNING, d5c ACTOR_STATE_SUSPENDED
fix kubectl ate suspend actor d5a -a demo-drill (0.4 s)
recover 200 in 0.321061 s: count=1 process_age_s=58.1
== 6 Postgres is away for 30 s
symptom running actor: 503 in 5.055445 s: actor demo-drill/d6 unavailable
symptom suspended actor: 503 in 5.053930 s: actor demo-drill/d6s unavailable
state kubectl ate get actor fails: Error: failed to connect to ate-api-server: no ready api pods in ate-system
fix kubectl -n ate-system scale statefulset postgres --replicas=1 (ready after 2.1 s)
recover running actor answers after 0.9 s
recover suspended actor answers after 0.4 s
state d6 ACTOR_STATE_RUNNING, d6s ACTOR_STATE_RUNNING
total 70 sA full pool gives a 503 after the 5 second parking budget, and a suspend of another actor frees the room. Without Postgres, a running actor and a suspended actor both return 503 after 5 seconds. The CLI fails too, because it finds no Ready ate-api-server pod for its port-forward.
What our run adds to the earlier sections
The drill ran on a different guest and a different install from the runs in Part 3 and Part 8. The symptom texts and the states agree for five of the six cases. The Postgres case does not agree, and you need both sets of symptoms.
| The drill, this section | The dependency test of When a dependency is down | |
|---|---|---|
| Machine | a second x86 server guest (16 vCPU, 31 GB) | a second x86 server guest (16 vCPU, 23 GB) |
| Postgres away for | about 30 s | 61 s |
| Running actor | 503 after 5.06 s, actor demo-drill/d6 unavailable | no answer until the client timeout of 9 s, then 500 error resuming actor, and no 503 |
| Suspended actor | 503 after 5.05 s, actor demo-drill/d6s unavailable | 503 after 5 s with the same unavailable text, or 500 error resuming actor |
| First resume that worked | 0.4 s after Postgres was ready | 27 s after Postgres was ready |
So a Postgres outage can look like either pattern to a running actor. One is a hang and then a 500. The other is a 503 with unavailable after 5 s, which is also the text of an ate-api-server outage. Check the database and the ate-api-server pods when you see either one.
The two runs differ in the machine, the install, and the length of the outage. We do not compare their seconds, and we did not find which condition changes the symptom. Plan for the longer recovery.
Limits of the drill
- It needs a kind cluster, because case 2 removes a directory inside the node container.
- It does not restart a node, and it does not stop
ate-api-serveror the Kubernetes API server. Part 8 has those runs. - It uses one small actor. An actor with more memory needs more time for each resume.
- It stops Postgres with a scale to zero. A network fault or a slow disk can look different to a client.
What to change for production
Do not run the drill on a cluster with users. Run it on a staging cluster after each Substrate upgrade, and keep the log. A changed symptom text or a new state in that log means that your alerts and your runbook need an update.
Add your own actor to the drill. Replace the image in the template with yours, and check what your application does after a revert to an older snapshot.
Reference
The methods, objects, states, commands, flags, errors, terms, and sources this manual uses, in one place.
API methods
The Substrate API at v1.0.0 is two gRPC services with 50 methods, and four plugin services that a third party can implement.
ate-api-server serves both services on one port, 443 in the Service named api in ate-system. The proto package is substrate.ateapi.v1, so the full name of a method is /substrate.ateapi.v1.Control/GetActor. The Go package is go.ate.dev/substrate/api/ateapi/v1. A client authenticates as section 5.1 describes, and each Control method is checked against the access policies of section 5.2.
The tables give each method, what it does at v1.0.0, and the kubectl ate command that calls it. A dash means that the CLI has no command for it. Our runs called every actor method, the egress policy methods, and the create, get, and list methods for templates, atespaces, and workers. UpdateActor ran in the template move of section 3.1. Our runs also created, replaced, and deleted an atespace access policy and read the global one, in section 5.2.
Our runs created three tags, started three actors from them, and deleted tags, in section 3.4. They did not change the scope of a tag.
Control: actors
| Method | What it does | kubectl ate |
|---|---|---|
CreateActor | Creates an actor from a template or a tag. The new actor is ACTOR_STATE_SUSPENDED. | create actor |
GetActor | Returns one actor with its state and snapshots. | get actor <name> |
ListActors | Lists actors in one atespace or in all. | get actor |
UpdateActor | Changes the template or the worker selector of an actor. Needs the uid and the version. | update actor |
SuspendActor | Checkpoints a running actor to a new snapshot. A suspended actor is returned unchanged. options.mode is asynchronous by default. | suspend actor |
ResumeActor | Resumes a suspended actor from its latest snapshot. A running actor is returned unchanged, with resumed=false. The router calls it for every request and relies on that reply. | resume actor |
RevertActor | Returns an actor to ACTOR_STATE_SUSPENDED at its durable snapshot. Accepts a crashed actor, a running actor, and a suspended actor with a node-local snapshot. Also accepts an actor already in ACTOR_STATE_REVERTING, for a retry. In our run that retry worked only after the node of the actor was back. A revert calls atelet on the node that the actor is assigned to. It continues without atelet only for a suspended actor whose latest snapshot is already durable. | revert actor |
DeleteActor | Stops the workload, releases the worker, and removes the record. Accepts any state. | delete actor |
MintActorJwt | Issues a token that states the identity of an actor. Only the egress gateway is allowed to call it. | - |
Control: egress policies
An egress policy is one object under one actor. Section 6.2 explains its rules.
| Method | What it does | kubectl ate |
|---|---|---|
CreateActorEgressPolicy | Creates the policy of an actor. | create egress-policy <actor> -f |
GetActorEgressPolicy | Returns the policy. | get egress-policy <actor> |
UpdateActorEgressPolicy | Replaces the policy. Needs metadata.uid and metadata.version. | update egress-policy <actor> -f |
DeleteActorEgressPolicy | Deletes the policy. The actor returns to default deny. | delete egress-policy <actor> |
Control: templates, tags, and atespaces
| Method | What it does | kubectl ate |
|---|---|---|
CreateActorTemplate | Creates a template. A reconciler then builds its golden snapshot. | create actor-template -f |
GetActorTemplate | Returns one template with the status of its golden snapshot. | get actor-template <name> |
ListActorTemplates | Lists templates. | get actor-template |
DeleteActorTemplate | Deletes a template, its golden actor, and its golden tag. | delete actor-template |
CreateTag | Gives a name to a copy of the durable snapshot of a suspended actor. Uploads the snapshot first if it is not uploaded. | create tag --actor |
GetTag | Returns one tag. | get tag <name> |
ListTags | Lists tags. | get tag |
UpdateTag | Changes the scope of a tag between atespace and published. | update tag --scope |
DeleteTag | Deletes a tag and its snapshot. Actors that still start from the tag cannot resume afterwards. It accepts the golden tag of a template, and create actor for that template then fails with Tag not found. | delete tag |
CreateAtespace | Creates an atespace. | create atespace |
GetAtespace | Returns one atespace. | get atespace <name> |
ListAtespaces | Lists atespaces. | get atespace |
DeleteAtespace | Deletes an empty atespace. Fails while an actor or a tag remains. | delete atespace |
Control: workers
ate-controller and atelet call most of these. An operator reads workers and rarely writes them.
| Method | What it does | kubectl ate |
|---|---|---|
ListWorkers | Lists workers with capacity and reserved resources. | get worker, top worker |
GetWorker | Returns one worker. | get worker <name> |
ListWorkerActorAssignments | Lists the actors on one worker. | - |
CreateWorker | Registers a worker when its pod has an address. The worker starts as unavailable. | - |
UpdateWorker | Updates the pool state that the controller observed. | - |
DeleteWorker | Removes a worker record. | - |
DrainWorker | Marks a worker as terminating. The scheduler places no new actor on it. One-way. | - |
MarkWorkerAvailable | Moves an unavailable worker to active. | - |
MarkWorkerUnavailable | Moves an active worker to unavailable, for example while its pod is not ready. | - |
Control: access policies
| Method | What it does | kubectl ate |
|---|---|---|
GetGlobalAccessPolicy | Returns the one policy for the whole install. | get access-policy --global |
CreateGlobalAccessPolicy | Creates it. The owners named at install can always do this. | create access-policy --global -f |
UpdateGlobalAccessPolicy | Replaces it. Empty bindings remove every grant except those of the install owners. | update access-policy --global -f |
GetAtespaceAccessPolicy | Returns the policy of one atespace. | get access-policy -a |
CreateAtespaceAccessPolicy | Creates it. | create access-policy -a -f |
UpdateAtespaceAccessPolicy | Replaces it. | update access-policy -a -f |
DeleteAtespaceAccessPolicy | Deletes it. | delete access-policy -a |
The global policy has no delete method.
WorkerService: for atelet only
atelet on each node calls these eight methods. A client of yours never does. They explain log lines and states that you see.
| Method | What it does |
|---|---|
AcquireWorkerEpoch | Gives a worker process a new epoch number when it starts. |
RegisterWorker | Records that the worker process runs under that epoch. Actors from an older epoch become crashed. |
SetWorkerSandboxRuntimes | Reports which sandbox classes and versions the worker can run and restore. |
MintWorkerActorCertificate | Issues a certificate for a worker that acts for one actor. Its SPIFFE path starts with worker-for-actor, so it is not an identity of the actor itself. |
MintWorkerActorJwt | Issues a token for a worker that acts for one actor. Its subject is worker-for-actor/<atespace>/<name>, and it speaks for the worker. |
RequestActorSuspend | Asks the control plane to suspend an actor on behalf of the worker. atelet forwards the call from a worker-side library, and no binary at v1.0.0 imports that library. So nothing triggers it. |
ReportActorSnapshotState | Reports that a pending snapshot reached the bucket, or that the actor left the node. |
ReportActorCrashed | Reports that a sandbox died. The actor moves from running to crashed. |
Plugin services
Substrate calls these services over gRPC. The repository includes one implementation of each, and another party can supply its own.
| Service | Methods | Who calls it | Purpose |
|---|---|---|---|
NodeProvider | FetchSnapshot, UploadSnapshot | atelet | moves snapshot files between a node and the bucket |
ControlProvider | CleanupSnapshot, CopySnapshot | ate-api-server | deletes a snapshot, and copies one for a tag |
AssetProvider | FetchAsset | atelet | downloads sandbox runtime files |
CredentialProvider | FetchSecret | the egress gateway | returns the secret that replaces a header, as section 6.5 shows |
Conventions that every method shares
- Names: an object is addressed by an atespace and a name. Workers and atespaces have a name only.
- Preconditions: each object has
metadata.uidandmetadata.version. An update or a guarded delete that names an old version fails withABORTED. - Wrong state: a lifecycle call from a state that it does not accept fails with
FAILED_PRECONDITION. A resume of a crashed actor is the case that we recorded. - Lists: list methods take
page_sizeandpage_token. - Deadline: the server ends any call after 10 minutes.
- Preview fields: a field behind a preview feature is rejected unless the server enables it and the client sends the header
Ate-Preview-Allowed: true.
Objects and fields
Three kinds are Kubernetes objects that describe worker capacity, and every other Substrate object is a record that only the Substrate API returns.
Field names appear here as kubectl ate -o yaml prints them, in camel case. The proto file has the same names in snake case. A field marked preview is rejected unless a preview feature is on, as the flags reference describes.
Kubernetes kinds, group ate.dev/v1
You manage these three with kubectl apply.
WorkerPool (namespaced)
| Field | Meaning |
|---|---|
spec.replicas | the number of worker pods. Fixed until something changes it. |
spec.workerImage | the worker image, which decides the sandbox runtime that the pod starts |
spec.sandboxClasses[].name | gvisor or microvm |
spec.sandboxClasses[].configRef.name | a SandboxConfig for that class. Without it the class default applies. |
metadata.labels | the labels of the WorkerPool object. A template selects a pool by these, and each worker of the pool reports them. |
spec.template.labels | labels added to the worker Deployment and its pods. No selector of Substrate reads them. |
spec.template.resources | requests and limits of the worker container. The limits become the capacity that the worker reports. |
spec.template.nodeSelector, tolerations, nodeAffinity, priorityClassName, annotations, serviceAccountName | passed to the worker pods |
status.replicas, status.readyReplicas | observed pods |
status.sandboxClasses[].conditions | a Resolved condition for each class, with a reason such as SandboxConfigNotFound |
apiVersion: ate.dev/v1
kind: WorkerPool
metadata:
name: ballast
namespace: lab-c
labels:
workload: ballast
spec:
replicas: 2
workerImage: localhost:5001/ateworker-gvisor@sha256:ad6957f41bccaa8bb5f3eb5b34b7812babb862f1a3187a11578c21a7d799491c
sandboxClasses:
- name: gvisor
template:
resources:
limits:
cpu: "2"
memory: 3Gi
requests:
cpu: 500m
memory: 3GiA worker container without limits reports the whole node as its capacity. Section 4.1 shows that case.
SandboxConfig (cluster)
| Field | Meaning |
|---|---|
spec.sandboxClass | gvisor or microvm |
spec.defaultVersion | the version that new actors start on |
spec.versions[].name | a version name |
spec.versions[].state | Enabled or Disabled |
spec.versions[].assets | for each architecture, the files of the runtime with a url and a sha256 |
The annotation sandboxconfig.ate.dev/is-class-default marks the default configuration of a class.
CSIDriverConfig (cluster, used by a preview feature)
| Field | Meaning |
|---|---|
spec.driverName | the CSI driver |
spec.controllerEndpoint | the address of the CSI controller |
spec.nodeSocketOverride | another path for the node socket |
spec.tls.enabled, spec.tls.usePodIdentity, spec.tls.serverName | TLS for the controller connection |
Metadata of every API object
| Field | Meaning |
|---|---|
metadata.atespace | the atespace. Empty for a worker, for an atespace, and for an access policy. The request for an access policy names its atespace in a separate field. |
metadata.name | the name, unique in its atespace |
metadata.uid | assigned by the server. A new object with an old name gets a new uid. |
metadata.version | grows with each write. An update must name the current value. |
metadata.createTime, updateTime, deleteTime | timestamps |
Actor
| Field | Meaning |
|---|---|
actorTemplate.atespace, actorTemplate.name | the template of the actor |
sourceTag | the tag that the actor started from, if any |
workerSelector.matchLabels | narrows the selector of the template. A pool must match both selectors (logical AND). A change takes effect at the next resume. |
status.state | one of seven states, as the states reference lists |
status.workerAssignment | present while a worker holds the actor: workerPod, workerPool, workerNamespace, workerPodIps, nodeName, workerEpoch, sandboxRuntime |
status.assignedNode | the node of the worker while the actor runs, or of the local snapshot after a suspend. It stays until the detach (default 30 minutes, --snapshot-detach-delay) or a revert. |
status.snapshots[] | the snapshots of the actor, described below |
status.lastAssignedGeneration | the last generation that a suspend assigned. A revert does not lower it, so it can be higher than the generation of the newest kept snapshot. |
status.crash.message, status.crash.crashTime | set when the actor enters ACTOR_STATE_CRASHED, and cleared by a revert |
status.externalVolumes | preview: the CSI volumes of the actor |
One entry of status.snapshots
| Field | Meaning |
|---|---|
uuid | the last part of the snapshot address |
generation | a counter. Each suspend adds one. |
owner | SNAPSHOT_OWNER_ACTOR, or SNAPSHOT_OWNER_TAG for a golden or tagged snapshot that the actor borrows |
fidelity | SNAPSHOT_FIDELITY_VOLUMES, SNAPSHOT_FIDELITY_ROOTFS, or SNAPSHOT_FIDELITY_MEMORY |
locality | the node that has a local copy. Absent after the node copy is removed. |
durableSnapshot.state | SNAPSHOT_STORAGE_STATE_PENDING until the upload finishes, then SNAPSHOT_STORAGE_STATE_COMPLETED |
durableSnapshot.object.snapshotUri | the address in the bucket |
sandboxRuntime | the class and the version attributes: architecture, cpu_features, sandbox_bundle_hash |
actorTemplateUid | the template that the snapshot was taken under |
After an asynchronous suspend an actor has two entries: the older completed one and the new pending one. Section 3.4 follows them.
ActorTemplate
| Field | Meaning |
|---|---|
workerSelector.matchLabels | selects worker pools by the metadata.labels of the WorkerPool object, in any namespace |
containers[] | name, image, command, args, env, volumeMounts, securityContext.capabilities, resources |
containers[].wakeupProbe | httpGet.path, httpGet.port, timeoutSeconds. The resume waits for one success. |
volumes[] | a name and one source: durableDir, image.reference, systemInfo, or external (preview) |
volumes[].systemInfo | one source: actorMetadata.items[] or trustBundle. An item has a path and a field: ACTOR_METADATA_FIELD_NAME, ACTOR_METADATA_FIELD_ATESPACE, or ACTOR_METADATA_FIELD_UID. |
resources.limits.cpuMillicores, resources.limits.memoryBytes | required. The worker reserves both while the actor runs. |
snapshotConfig.preferredFidelity | the highest fidelity of a suspend. A snapshot can have less, so read the fidelity of the snapshot itself. |
snapshotConfig.goldenSnapshotConfig.mode | GOLDEN_SNAPSHOT_MODE_ENABLED or GOLDEN_SNAPSHOT_MODE_DISABLED |
snapshotConfig.storageLocation | preview: a snapshot location for this template |
goldenEgressPolicy.rules | preview: egress rules for the golden actor only |
status.goldenSnapshotStatus | goldenTag, snapshotTime, errorMessage |
metadata:
atespace: lab-c
name: ballast-50
workerSelector:
matchLabels:
workload: ballast
containers:
- name: ballast
image: localhost:5001/ballast@sha256:93dba82ad560e7fc4ccb2a33a63d64713b519a7895ba3fd98130ce789eab7fd3
command:
- /ballast
- --mb=50
wakeupProbe:
httpGet:
path: /readyz
port: 80
resources:
limits:
cpuMillicores: 1000
memoryBytes: 320864256
snapshotConfig:
preferredFidelity: SNAPSHOT_FIDELITY_MEMORYEgressPolicy
One policy belongs to one actor. It has metadata and rules[]. Each rule has exactly one of three forms.
| Rule form | Fields | Meaning |
|---|---|---|
http | hostnames, ports, effects | plain HTTP to the named hosts. The port defaults to 80. |
https | hostnames, ports, effects | TLS that the gateway intercepts, so it can read and change the request |
tlsPassthrough | hostnames, ports | TLS that the gateway forwards unread. ports is required. |
ports is either all or a list in numbers. effects.replaceHeaders[] has header, prefix, and one source: credentialUri for a stored secret, or actorJwt with audiences and expirationSeconds. Section 6.5 shows both sources.
Tag, Atespace, Worker, AccessPolicy
| Object | Fields | Meaning |
|---|---|---|
| Tag | sourceActor, scope, status.snapshot | a named copy of a snapshot. scope is TAG_SCOPE_ATESPACE or TAG_SCOPE_PUBLISHED. |
| Atespace | metadata.name | a name that groups actors, templates, tags, and one access policy |
| Worker | workerNamespace, workerPool, workerPod, workerPodUid, nodeName, labels | one registered worker pod. Its name is opaque, so read the pod from workerPod and workerPodUid. labels is a copy of the metadata.labels of its WorkerPool. |
| Worker | status.ips | the IP addresses of the worker pod, at most one for each IP family |
| Worker | status.issuedEpoch, status.registeredEpoch, status.observedEpoch | the epoch counters of the worker process. A rise of registeredEpoch means an earlier process exited, and the server then crashes its actors. |
| Worker | status.state | WORKER_STATE_ACTIVE, WORKER_STATE_DRAINING, or WORKER_STATE_UNAVAILABLE |
| Worker | status.capacity, status.allocated | cpuMillicores, memoryBytes, and actors, as kubectl ate get worker prints them |
| Worker | status.sandboxClasses[] | the classes and runtime versions that the worker runs and restores |
| AccessPolicy | bindings[].role, bindings[].members | grants of a role to identities, for one atespace or for the whole install |
States
An actor has seven states, a snapshot has two storage states, and a worker has three, and each one changes what a request returns.
The names and numbers below come from the proto file at v1.0.0. Store and compare the names. The numbers changed before 1.0.0, as section 8.4 explains.
The "what a request returned" column comes from our runs, through kubectl ate proxy. Most rows are from the x86 server guest (24 vCPU, 47 GB, nested KVM). The rows for a stopped node and for the database outage are from a second x86 server guest (16 vCPU, 23 GB). Those are ACTOR_STATE_RESUMING and ACTOR_STATE_REVERTING in the first table, and the stopped-node rows of the second.
Actor states
| Number | State | Meaning | What a request returned in our runs |
|---|---|---|---|
| 1 | ACTOR_STATE_RESUMING | A worker is bound, and the sandbox is being restored. | 504 at the client timeout, then 503 actor lab-n/u1 unavailable, for an actor on a stopped node. One other actor stayed in this state after a database outage. |
| 2 | ACTOR_STATE_RUNNING | The actor holds a worker, and the API counts it as running. The state does not prove that its process answers. | 200 in 0.06 to 0.09 s. An actor whose process had exited stayed in this state and returned 503. |
| 3 | ACTOR_STATE_SUSPENDING | The worker writes a checkpoint. | A request that was open when the suspend started got a 503. A new request in this state was not tested. |
| 4 | ACTOR_STATE_SUSPENDED | The actor holds no worker. It has a snapshot, or it never ran. | 200 in 0.36 s, after a resume |
| 5 | ACTOR_STATE_CRASHED | The sandbox or its worker was lost, or a resume failed. | 503 in 0.06 s, AssignWorker prerequisite not met |
| 6 | ACTOR_STATE_DELETING | The workload is being stopped, and the record is removed next. | 503 in 0.06 to 0.08 s, AssignWorker prerequisite not met |
| 7 | ACTOR_STATE_REVERTING | The actor returns to its durable snapshot. | 503 in 0.06 s, AssignWorker prerequisite not met, after a revert that could not reach the node |
Number 0 is ACTOR_STATE_UNSPECIFIED, which no stored actor has. No state means idle, paused, or created. A new actor is suspended.
Transitions
| From | To | Trigger | Seen in our runs |
|---|---|---|---|
| none | SUSPENDED | CreateActor | yes, in under 1 s |
| SUSPENDED | RESUMING, then RUNNING | ResumeActor, which the router calls for each request | yes |
| RESUMING | CRASHED | The resume failed. In our runs the node-local snapshot was missing, or the worker had not registered after a node restart. The documentation adds a wakeup probe that times out. | yes, with a 500 error resuming actor |
| RUNNING | SUSPENDING, then SUSPENDED | SuspendActor | yes |
| SUSPENDED | SUSPENDED | SuspendActor on a suspended actor returns it unchanged. | from the code |
| SUSPENDING | CRASHED | The suspend could not record its snapshot. | from the code |
| RUNNING | CRASHED | The worker pod was deleted. | yes, within 0.6 s |
| RUNNING | CRASHED | The kernel killed the actor for memory. | yes, between 2 s and 4 s |
| RUNNING | CRASHED | The node restarted. | yes |
| RUNNING | RUNNING | The process exited and the sandbox stayed. No transition occurs. | yes, for at least 20 min |
| RUNNING | RUNNING | The node of the actor stopped. No transition occurs. | yes, for at least 10 min |
| RESUMING | CRASHED | The node of the actor stopped before or during the resume. | yes, 5 min 44 s after the stop command, after 5 min 33 s in RESUMING with requests |
| RUNNING or CRASHED | REVERTING, then SUSPENDED | RevertActor | yes, from CRASHED in 0.22 s |
| SUSPENDED with a node-local snapshot | REVERTING, then SUSPENDED | RevertActor, which discards the copy on the node. The snapshot itself is lost only if its upload is not yet complete. | yes, after the node of the actor was back: the next reply had count 4, and the two later calls were lost. With the node down, the actor stayed in REVERTING |
| REVERTING | REVERTING, then SUSPENDED | A second RevertActor, which continues a revert that stopped part-way | yes, in 0.2 s, after the node and its atelet were back. With the node down, 4 of 4 reverts failed and the state stayed REVERTING |
| REVERTING | CRASHED | The node of the actor returned, and we sent no command. | yes, in one run, 15 s after the node start. In four runs the actor was still REVERTING after 216 to 223 s |
| RUNNING | RUNNING | ResumeActor on a running actor returns it unchanged, with resumed=false. The router relies on this for every request. | yes, on every request to a running actor |
| any | DELETING, then removed | DeleteActor | yes |
Four rules follow from the table.
ResumeActoraccepts a suspended actor, an actor that is already resuming, and a running actor, which it returns unchanged. On a crashed actor it returnsFAILED_PRECONDITION.RevertActoraccepts an actor that is already reverting, so a second revert is the way out of a revert that stopped.- A crashed actor never leaves that state by itself. Something must call
RevertActororDeleteActor. - A revert returns the actor to its last completed durable snapshot. Calls that came after that snapshot are lost.
Snapshot storage states
| State | Meaning | How long, in our runs |
|---|---|---|
SNAPSHOT_STORAGE_STATE_PENDING | The snapshot is on one node and is not in the bucket. | 5 min 1 s to 5 min 11 s after an asynchronous suspend, with default timers, for four actors |
SNAPSHOT_STORAGE_STATE_COMPLETED | The snapshot is in the bucket. | reached before a synchronous suspend returns |
The locality field of a snapshot names the node that still has a local copy. It was set for 30 minutes after the suspend in our run, and absent after that. The owner field is SNAPSHOT_OWNER_ACTOR for a snapshot of the actor itself and SNAPSHOT_OWNER_TAG for a golden or tagged snapshot.
Snapshot fidelity
| Value | What a resume brings back |
|---|---|
SNAPSHOT_FIDELITY_VOLUMES | the durable directories. The processes start again. |
SNAPSHOT_FIDELITY_ROOTFS | the durable directories and the changes to the root filesystem. The processes start again. |
SNAPSHOT_FIDELITY_MEMORY | all of that and the memory of the processes, which continue. |
Worker states
| State | Meaning |
|---|---|
WORKER_STATE_UNAVAILABLE | Registered, and the scheduler places no actor on it. A new worker starts here. |
WORKER_STATE_ACTIVE | The scheduler places actors on it while it has room. |
WORKER_STATE_DRAINING | Terminating. No new actor is placed, and bound actors stay until they are suspended. One-way. |
kubectl ate get worker prints the state with the actors, the CPU, and the memory that are reserved against the capacity.
Durability modes of a suspend
| Value | CLI | When the call returns |
|---|---|---|
DURABILITY_MODE_ASYNCHRONOUS | --mode async, the default | when the snapshot is on the node |
DURABILITY_MODE_SYNCHRONOUS | --mode sync | when the new snapshot is in the bucket. An actor that is already suspended is returned as it is, and its snapshot can still be pending on the node. |
kubectl ate commands
The kubectl ate plugin at v1.0.0 has 11 verbs over seven resources, and most commands are one call to the Substrate API.
The plugin is the binary kubectl-ate. It finds ate-api-server through your kubeconfig and opens a port-forward for each command, unless you pass --endpoint. Section 2.2 shows how to install and use it. In our runs we built it from the checkout with go install ./cmd/kubectl-ate.
Three kinds of command do more or less than one call. logs actor also reads the logs of the worker pod through the Kubernetes API. proxy actor sends its traffic through atenet-router and not through ate-api-server. The admin commands write Kubernetes Secrets and call no Substrate API.
Global flags
Error: unknown flag "-A"
Usage:
kubectl-ate get worker [worker-name ...] [flags]
Aliases:
worker, workers
Flags:
-a, --atespace string filter worker pods hosting actors in a specific atespace
-h, --help help for worker
-n, --namespace string scope output to a specific Kubernetes namespace
--sandbox-class string filter by sandbox class (e.g. gvisor, microvm)
-l, --selector string filter by worker pool labels
Global Flags:
--context string kubeconfig context to use
--endpoint string manual override for the gRPC target (e.g., localhost:8080); if omitted, automatically port-forwards
--kubeconfig string path to the kubeconfig file
-o, --output string output format: table, json, or yaml (default "table")
--preview allow the use of preview features enabled on the server
--token-file string path to a bearer token for ate-api authentication, or - for stdin (defaults to a Kubernetes ServiceAccount token)
--trace enable tracing for the request
-v, --verbose count output verbosity; may be repeated (e.g. -v, -vv, -vvvvv)| Flag | Use it when |
|---|---|
--context | the machine has more than one cluster. A wrong context sends the command to a cluster with no Substrate. |
--endpoint | you keep one port-forward open and run many commands |
--token-file | you act as your own identity. Without it the CLI uses a shared ServiceAccount and prints a warning, as section 5.1 explains. |
-o yaml | you need the uid, the version, or the snapshots of an object |
--preview | the server has a preview feature on and you want to use it |
--version | you want the version of the plugin. It is a flag of the root command only, so the help text of a subcommand above does not list it. It has no -v short form, because -v means --verbose. |
Actors
| Command | Flags | What it does |
|---|---|---|
get actor [name ...] | -a, -A | Lists actors in one atespace or in all, or prints the named ones. |
create actor <name> | --template, -a, --tag | Creates a suspended actor from a template or from a tag. --template takes atespace/name or a name. |
update actor <name> | -a, --template, --worker-selector | Points the actor at another template, or changes its worker selector. |
suspend actor <name> | -a, --mode | Suspends. --mode is async by default, or sync. |
resume actor <name> | -a | Resumes without a request. |
revert actor <name> | -a | Returns the actor to its durable snapshot. |
delete actor <name> | -a | Deletes the actor in any state. |
logs actor <name> | -a, -f, -c, --source | Prints logs. --source is all, containers, or lifecycle. -c _sandbox prints the sandbox runtime log. |
proxy actor <name> [[local:]remote ...] | -a, --address | Opens a local port that forwards to the actor through the router. Listens on 127.0.0.1 by default. |
Templates, tags, and atespaces
| Command | Flags | What it does |
|---|---|---|
get actor-template [name ...] | -a, -A | Lists or prints templates. |
create actor-template -f <manifest> | -f | Creates one template from a JSON or YAML file, or from standard input with -. |
delete actor-template <name> | -a | Deletes the template and its golden snapshot. |
get tag [name ...] | -a, -A | Lists or prints tags. |
create tag <name> | -a, --actor, --scope | Tags the snapshot of a suspended actor. --scope is atespace or published. |
update tag <name> | -a, --scope | Changes the scope. |
delete tag <name> | -a | Deletes the tag and its snapshot. |
get atespace [name ...] | none | Lists or prints atespaces. |
create atespace <name> | none | Creates an atespace. |
delete atespace <name> | none | Deletes an empty atespace. |
The CLI has no command that updates a template. To change one, create a template with a new name and point actors at it with update actor --template.
Egress policies and access policies
| Command | Flags | What it does |
|---|---|---|
get egress-policy <actor> | -a | Prints the policy of one actor. |
create egress-policy <actor> -f <manifest> | -a, -f | Creates the policy. |
update egress-policy <actor> -f <manifest> | -a, -f | Replaces the policy. The manifest must carry metadata.uid and metadata.version. |
delete egress-policy <actor> | -a, --uid, --version | Deletes the policy, with optional preconditions. |
get access-policy | -a or --global | Prints the access policy of an atespace, or the global one. |
create access-policy -f <manifest> | -a or --global, -f | Creates it. |
update access-policy -f <manifest> | -a or --global, -f | Replaces it. |
delete access-policy | -a, --uid, --version | Deletes the policy of an atespace. The global policy has no delete. |
Workers
| Command | Flags | What it does |
|---|---|---|
get worker [name ...] | -n, -a, -l, --sandbox-class | Lists workers with state, actors, CPU, and memory against capacity. |
top worker | -n, -a, -l, --sandbox-class | Prints the CPU and memory use of workers. |
Workers have no atespace. get workers lists the workers of all Kubernetes namespaces by default, and get workers -A fails with unknown flag "-A". The -n and -l flags narrow the list, and -a keeps only the workers that host an actor of that atespace.
Admin commands
These commands read and write Kubernetes Secrets directly. They take --name and --secret-namespace.
| Command | Flags | What it does |
|---|---|---|
admin make-ca-pool | --ca-id, --key-type, --validity | Creates a pool with one CA. The key type is ECDSAP256 by default, and the validity is 365 days. |
admin make-jwt-pool | --alg, --key-id | Creates the pool that signs actor JWTs. The algorithm is ES256 by default, or RS256. |
admin list-jwt-keys | none | Lists the keys of a pool. |
admin add-jwt-key | --alg, --key-id | Adds a key that verifies and does not yet sign. |
admin activate-jwt-key | --key-id | Makes a key the signing key. |
admin remove-jwt-key | --key-id | Removes a key. |
Section 5.3 explains the tokens, and section 8.5 notes that the rotation is manual.
Commands that older text names and v1.0.0 does not have
kubectl ate pause actor and the flag --template-ref do not exist at v1.0.0. Section 8.4 lists the replacements.
Flags and preview features
The flags that change what an actor does are few: two snapshot timers on atelet, the parking budget on the router, and the dataplane and image choice in the installer.
Each table gives the literal flag and the default that the code sets at v1.0.0. Where a recorded argument list shows it, the table also gives the value of our install. A component has more flags than these. The tables hold the ones that this manual uses or that change behaviour you can observe.
The installer, ate-setup
ate-setup reads each setting from a flag, then a configuration file, then an environment variable, then the default.
| Flag | Default | What it does | Section |
|---|---|---|---|
--image-repo, --image-tag | none | Installs published images. Without them the installer builds every image from the checkout. Each flag requires the other. | 2.1 |
--kind | false (environment variable ATE_INSTALL_KIND) | Uses the kind overlays: the bundled Postgres, rustfs, and the local registry. | 2.1 |
--namespace | ate-system | The namespace of the control plane. | 2.4 |
--credential-provider | none, required | A JSON object. {"name":"k8s.io"} installs the provider that reads Kubernetes Secrets. {"enabled":false} turns injection off. | 6.5 |
--ingress-allowed-spiffe-ids | none | The client identities that the router accepts. With no value, no client reaches an actor. | 5.1 |
--atenet-dataplane | envoy | envoy or agentgateway. A second run with the other value switches a live cluster. | 6.6 |
--sandbox-classes | gvisor,microvm | The sandbox classes to prepare. The default is the same on kind. | 4.2 |
--preview | empty | The preview features to turn on in every component. | this page |
--cluster-size | size0 | size10 assumes a PostgreSQL node that you provide. It makes the bundled Postgres request 80 CPUs and 140 GiB of memory, and it changes the database settings to match. It does not create or reserve the node. Do not use it on kind or on a small cluster. | 8.1 |
--rollout-timeout | 60s | How long the installer waits for each workload. | 2.1 |
--cordon-system-infra-components | off | Keeps the system components off the worker nodes. With size10, this flag is what puts Postgres alone on its node. | 8.4 |
--ateapi-log-level | none: keeps the installed level, else info | The log level of ate-api-server. | - |
--snapshot-location | none. The kind profile sets gs://ate-snapshots/. | The --snapshot-location of ate-api-server. Required unless PerTemplateSnapshotLocation is on. | 3.4 |
--actor-jwt-issuer | https://idp.<namespace>.svc | The issuer in each actor JWT. | 5.3 |
--actor-jwt-algorithm | ES256 | The signing algorithm of a new actor JWT key: ES256 or RS256. | 5.3 |
--ateapi-authz-bootstrap-owners | system:serviceaccount:<namespace>:ate-client | The --authz-bootstrap-owners of ate-api-server. | 5.2 |
--e2e-tuning | false | Sets the atelet timers to an upload delay of 2 s and a detach delay of 30 s. It is for test runs. | 3.4 |
--setup-csi | none | Also installs a CSI driver: nfs, hostpath, or both. | - |
--record-dir | the cache directory of your user | Where the installer writes the record of each install. | 2.4 |
The help text of --credential-provider says that a provider requires Envoy. In our run the installer accepted {"name":"k8s.io"} with agentgateway, and injection worked.
atelet: when a snapshot becomes durable
| Flag | Default | What it does |
|---|---|---|
--snapshot-upload-delay | 5m | After an asynchronous suspend, how long the snapshot waits on the node before the upload starts. |
--snapshot-detach-delay | 30m | How long a suspended actor stays assigned to its node, with the node copy kept. Must not be shorter than the upload delay. |
--snapshot-upload-timeout | 2m | The limit for one upload attempt, before a size allowance. Each failure in a row doubles the limit of the next attempt, up to 8 times the first. |
--snapshot-upload-est-throughput-mib | 100 | The size allowance. Each attempt gets the time that the snapshot needs at this rate, in MiB per second, on top of the timeout. |
--snapshot-upload-workers | 2 | Uploads that run at the same time on one node. |
--snapshot-detach-workers | 2 | Detaches that run at the same time on one node. |
--snapshot-report-workers | 1 | Snapshot state reports to ate-api-server that run at the same time. |
--snapshot-report-give-up-after | 10m | How long atelet retries a failed state report. |
--snapshot-cache-min-age | 10m | A cached snapshot file younger than this is never removed. |
--snapshot-cache-fetch-timeout | 10m | The limit for the download of one cached snapshot file. A restore that waits for a download that hangs waits this long. |
--actor-stats-poll-interval | 1m | How often atelet samples the resource use of actors. |
--drain-delay | 0 | How long atelet accepts new calls after SIGTERM. |
--drain-timeout | 5m | How long atelet waits for its work to end when it stops. |
--log-level | info | Our source install ran with debug. |
atelet --localhost-registry-replacement=kind-registry:5000 --grpc-server-cred-bundle=/run/podidentity.podcert.ate.dev/credential-bundle.pem --client-ca-certs=/run/podidentity.podcert.ate.dev/trust-bundle.pem --log-level=debug --preview=For one experiment we shortened both timers so that a resume from the bucket took seconds to set up. Section 3.3 uses that run.
… --log-level=debug --preview= --snapshot-upload-delay=3s --snapshot-detach-delay=10sShort timers move snapshots to the bucket sooner. A resume after the detach delay then reads from the bucket, which is slower. A resume before it still uses the copy on the node. Do not copy the 3 s value into a cluster that serves users.
The router: parking and ports
| Flag | Default | What it does |
|---|---|---|
--parked-request-budget | 5s | How long the router holds a request that has no worker yet. |
--parked-request-max | 1024 | How many requests the router holds at once. 0 turns parking off. |
--parked-request-retry-interval | 100ms | The first wait between two tries for a parked request. |
--parked-request-retry-factor | 1.1 | The growth of that wait. |
--parked-request-retry-jitter | 0.1 | A random part of each wait, as a fraction between 0 and 1. |
--extproc-max-requests | 0 | The request limit of the Envoy connection to the router process. 0 means two times --parked-request-max, and at least 1024. |
--drain-delay | 13s | How long the router serves after SIGTERM before it starts to drain. |
--drain-timeout | 0 | The limit for the drain. 0 means the parking budget plus 10 s plus a margin of 5 s, which is 20 s with defaults. An explicit value must not be less than the parking budget. Parked requests finish inside it. |
--route-timeout | 5m | The limit for one whole request. |
--ingress-allowed-spiffe-ids | none | The allowlist that the installer flag sets. |
--port-https | 8443 | The listener for requests with the ate-target-actor header. |
--port-connect-tls | 8444 | The listener for CONNECT tunnels, which kubectl ate proxy uses. |
--upstream-connect | true | The router reaches the worker with CONNECT. |
--egress-policy-cache-ttl | 10s | How long the egress gateway keeps a policy that it read. |
atenet-router router --mode=ingress --namespace=ate-system … --port-https=8443 … --port-connect-tls=8444 … --drain-delay=13s --ingress-allowed-spiffe-ids=spiffe://cluster.local/ns/ate-system/sa/atenet-client --preview=The 5.05 to 5.16 s in every no worker has room response of our runs is this budget plus the request time. Section 4.4 explains the mechanism.
ate-api-server and ate-controller
| Component | Flag | Default | What it does |
|---|---|---|---|
ate-api-server | --snapshot-location | none. Required unless PerTemplateSnapshotLocation is on. | The address prefix for durable snapshots, such as gs://ate-snapshots/. Without it and without that feature, the server does not start. |
ate-api-server | --postgres-schema | substrate | The schema that holds every table. |
ate-api-server | --postgres-pool-max-conns | 0 | The size of the read-write connection pool, which the authorizer shares. 0 keeps the default of the connection string or of the driver. The owner, watch, and readiness pools do not follow it. |
ate-api-server | --template-resync-interval | 20s | How often the reconciler checks templates and their golden snapshots. |
ate-api-server | --authz-bootstrap-owners | none. Required, at least one. ate-setup sets ate-client. | Identities that are global owners for as long as the flag lists them, whatever the stored global access policy says. |
ate-api-server | --actor-jwt-issuer | none. Required. ate-setup sets https://idp.ate-system.svc. | The issuer in each actor JWT. It must be an https address with no query and no fragment. |
ate-api-server | --drain-delay, --drain-timeout | 13s, 15s | The wait before and during the stop of a replica. |
ate-controller | --dns-egress-policy | empty | The install manifest sets enforce-in-atunnel, so that the DNS relay enforces the egress policy. |
ate-controller | --worker-sync-concurrency | 2 | Worker pods that the controller registers as Worker records at the same time. |
Two limits are fixed in the code and have no flag. A worker gives its actors 30 minutes after SIGTERM. ate-api-server ends any call after 10 minutes.
Preview features
A preview feature is off unless two switches agree.
- The server switch. Every component takes
--previewwith a list of names, orALL. The installer flag passes one value to all of them. - The client switch. A request must carry the header
Ate-Preview-Allowed: true.kubectl ate --previewsends it.
| Name | Field that it allows | Default |
|---|---|---|
ExternalVolumes | volumes[].external in a template, with a CSIDriverConfig | off |
GoldenEgressPolicy | goldenEgressPolicy in a template | off |
PerTemplateSnapshotLocation | snapshotConfig.storageLocation in a template | off |
The documentation says that an object that was created while a feature was on stays usable after the feature is turned off. That is true for validation only: the server does not check an unchanged value again. The code has two runtime effects that the documentation does not state.
| Feature turned off | What happens to an object that already uses it |
|---|---|
ExternalVolumes | A resume of an actor that has external volumes fails. The error is external volumes are not supported unless the ExternalVolumes preview gate is enabled. |
PerTemplateSnapshotLocation | The server ignores snapshotConfig.storageLocation of the template. New snapshots go to --snapshot-location. |
GoldenEgressPolicy | We found no runtime check. |
We did not run these two cases. Turn a feature off only after you have removed the objects that use it.
We tried the four combinations of the two switches once, on the x86 server guest. The request was kubectl ate create actor-template with a file that has a goldenEgressPolicy field.
--preview on ate-api-server | --preview on kubectl ate | Result |
|---|---|---|
| empty | not passed | Error: failed to create actor template: rpc error: code = InvalidArgument desc = actor_template.golden_egress_policy: Forbidden |
| empty | passed | the same error |
GoldenEgressPolicy | not passed | the same error |
GoldenEgressPolicy | passed | accepted, with a golden tag within 30 s |
The error names the field and not the feature. A read of the new template with kubectl ate get needed no client flag. In this test only ate-api-server had the server switch, and we restored its arguments afterwards. Every other argument list that we recorded has --preview= with no value, so all three features were off in all other runs.
Kubernetes features that the cluster must have
Substrate issues its certificates with Kubernetes pod certificates. The cluster script for kind turns on three Kubernetes features and one API group for them.
| Name | Kind |
|---|---|
ClusterTrustBundle | Kubernetes feature |
ClusterTrustBundleProjection | Kubernetes feature |
PodCertificateRequest | Kubernetes feature |
certificates.k8s.io/v1beta1 | API group and version |
A cluster without them cannot start the Substrate components. We ran Kubernetes 1.37.0 in every test.
Errors
Every error text that our runs recorded, in alphabetical order, with where it appears, what it means, and the section that explains it.
Search this page for a fragment of the text in your log. Names such as lab-c/r1 are the atespace and the actor of our run, and your text has your own. Section 8.3 gives the fix for each one, grouped by symptom. All strings are from Agent Substrate v1.0.0, except the last table, which is from kagent v1.0.0. One table lists texts that we read in the code and did not record, and it says so.
From a request to an actor
The bodies in this table are from the Envoy dataplane, unless a row names agentgateway. On agentgateway, read the status code and the ate-error-reason response header, as section 8.3 explains.
| Text | Code | Meaning | Section |
|---|---|---|---|
actor ate-demo-counter/does-not-exist not found | 404, Envoy router | No actor has that name in that atespace. | 6.1 |
actor lab-c/b0-5 unavailable: no worker has room for the actor | 503 after 5.06 s | No worker of a matching pool had free CPU and memory inside the parking budget. | 4.4 |
actor lab-c/r1 unavailable: AssignWorker prerequisite not met for Actor: lab-c/r1 (got: ACTOR_STATE_CRASHED, want ACTOR_STATE_SUSPENDED) | 503 in 0.07 s | The actor is crashed, and only a suspended actor can resume. | 3.5 |
actor lab-e/e-run unavailable | 503 after 3 to 5 s | The router got no answer from ate-api-server. We also saw this text while Postgres was down, so check both. | 8.2 |
actor lab-n/q2 unavailable: AssignWorker prerequisite not met for Actor: lab-n/q2 (got: ACTOR_STATE_REVERTING, want ACTOR_STATE_SUSPENDED) | 503 in 0.06 s | A revert started while the node of the actor was down, and it did not finish. The state stays until the node is back. | 3.5, 8.2 |
actor lab-n/d2 unavailable: AssignWorker prerequisite not met for Actor: lab-n/d2 (got: ACTOR_STATE_DELETING, want ACTOR_STATE_SUSPENDED) | 503 in 0.07 s | A delete started while the node of the actor was down, and it did not finish. The name is blocked until the node is back. | 8.2 |
actor lab-n/u1 unavailable | 503 after 5 to 8 s, after one or two 504s at the client timeout of 10 s | The actor is in ACTOR_STATE_RESUMING on a node that stopped. | 4.3 |
actor xa/x2 unavailable: no worker has room for the actor | 503 after 5.06 s | The workerSelector of the template matched no WorkerPool. The text is the same as for a full pool, and the template has no golden tag. | 3.6 |
error resuming actor lab-c/x1 | 500 in 0.08 s | The resume failed because the node-local snapshot was gone. The actor is now crashed. | 3.5 |
error resuming actor lab-c/r1 | 500 in 0.41 s | The request arrived soon after a node restart, before the workers had registered. The actor is now crashed. | 8.2 |
error resuming actor lab-e/e-run | 500 after a wait of up to 9 s | ate-api-server could not read Postgres. | 8.2 |
ingress failed | any failure code on the agentgateway router: 404, 500, 503, and 504 in our runs, and 401 and 403 in the code | The agentgateway body for every ingress failure except a full pool. It does not name the cause. We got it for an unknown actor, for a missing routing header, and with Postgres or ate-api-server away. Read the status code and the ate-error-reason header. | 8.3, 8.2, 6.6 |
method not allowed | 405, agentgateway router, port 8444 | A POST on the port that accepts CONNECT only. | 6.1 |
no worker has room for the actor, with no text in front of it | 503, agentgateway router, from the code | The full pool on agentgateway. The body has no actor ... unavailable: text in front. | 4.4 |
invalid actor reference | 404, Envoy router | The ate-target-actor header is not atespace/name. | 6.1 |
upstream connect error or disconnect/reset before headers. reset reason: connection termination | 503 in about 0.06 s | The process in the sandbox exited. The actor still reads RUNNING, for 20 minutes in our longest watch. | 8.3, 3.5 |
| The same text, after a longer delay | 503 after 0.50 to 5.07 s | The node of a running actor stopped. The actor still reads RUNNING. All 150 requests of our probe had this delay. | 8.3, 4.3 |
| The same text, on a request that was open | 503 after 2.2 s | A suspend stopped the actor during the request. The actor reads SUSPENDED, and the next request resumes it. | 3.3 |
| code 000, no body | client timeout | The request hung. In our runs the cause was Postgres, or the Kubernetes API server for requests through kubectl ate proxy. | 8.2 |
Texts from the code that our runs did not record
These bodies are in the router code of the Envoy dataplane at v1.0.0. X is the atespace and the name of the actor.
| Text | Code | Meaning | Section |
|---|---|---|---|
actor "X" unavailable: router at capacity | 503 | The parking lot of the router was full, so the router shed the request after one failed resume attempt. | 4.4 |
actor X unavailable: another operation is in progress for this actor | 503 | Another workflow held the lease of the actor for the whole parking budget. | 4.4, 8.1 |
actor X authentication required | 401 | ate-api-server did not accept the identity of the router. | 5.2 |
actor X access denied | 403 | ate-api-server refused the resume call of the router. | 5.2 |
actor X request timed out | 504 | The resume did not finish before its deadline. | 8.2 |
request for actor X canceled by client | 408 | The client closed the request while the router waited. The client does not see this reply, and the router log does. | 4.4 |
From a TLS handshake with the router
| Text | Dataplane | Meaning | Section |
|---|---|---|---|
tlsv13 alert certificate required | agentgateway | The client sent no certificate. | 5.1 |
certificate unknown | Envoy | The client certificate has an identity that the allowlist does not hold. | 5.1 |
Connection reset by peer | agentgateway | The same cause as the row above. | 6.6 |
SSL: no alternative certificate subject name matches target hostname 'atenet-router.ate-system.svc.cluster.local' | both | The client used the long name of the Service. The router certificate is valid for atenet-router.ate-system.svc. | 6.1 |
From inside an actor, on the way out
| Text | Dataplane | Meaning | Section |
|---|---|---|---|
actor egress policy denied | agentgateway, HTTP 403 | No rule of the egress policy allows the destination. | 6.2 |
egress denied | Envoy, HTTP 403 | No rule of the egress policy allows the destination. | 6.2 |
status: NXDOMAIN | both | The DNS relay answers only for names that a rule allows. | 6.3 |
curl: (6) Could not resolve host: drip.egress-target.svc.cluster.local | both | The same cause, as curl reports it. | 6.3 |
upstream connection failed | agentgateway, HTTP 503 | The host of an https rule has a certificate that the gateway does not trust, such as one from a private CA. The gateway log has invalid peer certificate: UnknownIssuer. | 6.4 |
upstream connect error or disconnect/reset before headers. reset reason: remote connection failure | Envoy, HTTP 503 | The same cause. The gateway log has CERTIFICATE_VERIFY_FAILED. | 6.4 |
From kubectl ate and the installer
| Text | Meaning | Section |
|---|---|---|
actor "f-deny" in atespace "lab-f" has no egress policy | get egress-policy on an actor that has none. The actor is at default deny. | 6.2 |
egress_policy.rules[2].tls_passthrough.ports: Required value | A pass-through rule needs ports. | 6.2 |
Error: failed to connect to ate-api-server: no ready api pods in ate-system | No ate-api-server pod is ready. In one run the cause was Postgres. In another it was a node restart, before ate-api-server was ready. | 8.2, 4.3 |
Error: failed to create actor: rpc error: code = AlreadyExists desc = Actor d2 already exists | A create with the name of an actor that a failed delete left in ACTOR_STATE_DELETING. Its node was down. | 8.2 |
Error: failed to create actor: rpc error: code = FailedPrecondition desc = Tag not found | create actor --template for a template whose golden tag was deleted. get actor-templates still lists the tag id. | 3.4 |
Error: failed to create tag: rpc error: code = Aborted desc = another operation is in progress for this actor | create tag on an actor with a resume in progress on a node that had stopped. | 8.2 |
Error: failed to create tag: rpc error: code = FailedPrecondition desc = Actor lab-n/t1 must be ACTOR_STATE_SUSPENDED to be tagged (got: ACTOR_STATE_RESUMING) | create tag on an actor that is not suspended. We also saw it with got: ACTOR_STATE_CRASHED. | 3.4, 8.2 |
Error: failed to create actor template: rpc error: code = InvalidArgument desc = actor_template.golden_egress_policy: Forbidden | The template has a preview field, and the server flag or the client flag --preview is missing. | 8.5 |
Error: failed to list actors: rpc error: code = PermissionDenied desc = permission denied: principal "user:system%3Aserviceaccount%3Aate-users%3Aalice" lacks "can_list_actors" on "atespace:lab-v" | The token is valid, and no access policy allows the call. The same sentence names can_create_actor, can_list_workers, can_list_atespaces, or can_create_access_policy for other calls. | 5.2 |
Error: failed to list actors: rpc error: code = Unauthenticated desc = invalid bearer token | The token file holds no valid token for the audience api.ate-system.svc. | 5.1 |
Error: failed to resume actor: rpc error: code = Unavailable desc = while calling atelet Restore | A resume in the first seconds after the control plane returned from a node restart. The worker had not registered again. | 4.3 |
Error: failed to resume actor: rpc error: code = Unavailable desc = worker b17a6492-9643-42f1-bfdb-8bb88ee46772 assigned to actor lab-c/w1 is unavailable | A resume a few seconds later. The worker from before the restart was marked unavailable. We got the same text from resume actor for an actor on a node that had stopped. | 4.3, 8.2 |
Error: failed to revert actor: rpc error: code = Aborted desc = another operation is in progress for this actor | A revert while a resume was in progress on a node that had stopped. | 3.5 |
Error: failed to revert actor: rpc error: code = FailedPrecondition desc = Actor lab-n/p3 is not in a revertable state (got: ACTOR_STATE_RESUMING, want one of [ACTOR_STATE_RUNNING ACTOR_STATE_CRASHED] | A revert of an actor that stayed in ACTOR_STATE_RESUMING on a stopped node. Our trace cut the end of the text. In the code the text ends with , or ACTOR_STATE_SUSPENDED with a node-local snapshot), which is the third state that a revert accepts. | 3.5 |
Error: failed to revert actor: rpc error: code = Unavailable desc = while calling atelet Terminate | A revert of an actor whose node had stopped. The actor stayed in ACTOR_STATE_REVERTING, and each later revert during the outage failed with the same text. One more revert worked in 0.2 s after the node and its atelet were back. | 3.5, 8.2 |
Error: failed to suspend actor: rpc error: code = FailedPrecondition desc = MarkSuspending prerequisite not met for Actor: lab-n/d2 (got: ACTOR_STATE_RESUMING, want ACTOR_STATE_RUNNING) | A suspend of an actor that is not running. Ours was in ACTOR_STATE_RESUMING on a node that had stopped. | 8.2 |
Error: failed to suspend actor: rpc error: code = Internal desc = internal server error: workflow failed at step CallAteletSuspend: actor lab-c/j1 crashed | A suspend found a dead sandbox. The actor is now crashed. | 8.3 |
Error: failed to update actor: rpc error: code = FailedPrecondition desc = actor must be ACTOR_STATE_SUSPENDED to change its actor template (got: ACTOR_STATE_RUNNING) | update actor --template on a running actor. Suspend it first. | 3.1 |
Error: manifest for actor "f-main" in atespace "lab-f" lacks the metadata.uid and metadata.version preconditions that "get egress-policy -o yaml" prints | An update must name the version that it replaces. | 6.2 |
Error: rpc error: code = Unavailable desc = while calling atelet Terminate | A delete actor for an actor whose node had stopped. The actor stayed in ACTOR_STATE_DELETING. | 8.2 |
Error: unknown flag "-A" | get workers has no all-atespaces flag. It lists every namespace by default. | 2.2 |
Error: while building the envoy-dataplane image: exit status 125 | The source install needs docker buildx. | 2.1 |
global has no access policy | get access-policy --global on a new install. Only the bootstrap owners have a global role. | 5.2 |
Unable to connect to the server: EOF | kubectl could not reach the Kubernetes API server. Actors still served requests. | 8.2 |
unknown flag: --platform | Docker has no buildx plugin. This line comes before the build error above. | 2.1 |
Warning: no --token-file given; acting as the shared identity system:serviceaccount:ate-system:ate-client rather than as yourself. Pass --token-file to act as yourself. | No --token-file was given, so the CLI uses the shared identity. | 5.1 |
From the API, as status codes
| Code | When | Section |
|---|---|---|
FAILED_PRECONDITION | a lifecycle call from a state that it does not accept, such as a resume of a crashed actor, or a delete of an atespace that is not empty | the states reference |
ABORTED | a write that named an old version, or two workflows on one object. The texts in the code are concurrent update conflict, please retry for an actor or a worker, EgressPolicy version conflict, and another operation is in progress for this actor. | the API reference |
InvalidArgument | a manifest that fails validation, such as a rule without ports | 6.2 |
Internal | a workflow step failed. The text names the step. | 8.3 |
From the kagent CLI
Glossary
Each term below has one meaning across the manual, and the last column names the section that explains it.
The meanings come from the Agent Substrate source and documentation at v1.0.0 and from our runs. Terms in code font are literal names that you type or read in output.
| Term | Meaning in this manual | Explained in |
|---|---|---|
| Access policy | A list of bindings that grants roles on the API, for one atespace or for the whole install | 5.2 |
| Actor | One isolated workload with an identity and a state that Substrate can suspend to a snapshot and resume on a fitting worker. While a node holds its last snapshot, it resumes on that node | 1.1 |
| Actor JWT | A signed token that states the identity of one actor, which the egress gateway can attach to an outbound request | 5.3 |
ACTOR_STATE_CRASHED | The state of an actor whose sandbox, worker, or restore was lost. Only a revert or a delete leaves it. | 3.5 |
| ActorTemplate | The record that describes the containers, the resource limits, and the snapshot fidelity of the actors made from it | 3.1 |
| Agent (kagent) | The pair of one AgentTemplate and one Harness, which kagent compiles into one ActorTemplate | 7.1 |
| agentgateway | An open-source proxy that Substrate can run as its dataplane in place of Envoy | 6.6 |
| AgentTemplate | The kagent object that holds the model, the prompt, and the tools of an agent | 7.1 |
| Asynchronous suspend | A suspend that returns when the snapshot is on the node, before the upload to the bucket. The default. | 3.3 |
ate-api-server | The Substrate API server. It serves both gRPC services and is the only component that reads and writes Postgres. | 1.2 |
ate-controller | The component that turns each WorkerPool into worker pods and registers them as workers | 2.4 |
ate-idp-server | The component that publishes the public keys for actor JWTs | 5.3 |
ate-setup | The installer program in the Substrate repository | 2.1 |
atelet | The Substrate agent on each node. It drives the workers of its node and moves snapshots to and from the bucket. | 2.4 |
atenet-egress | The egress gateway: the dataplane pod that every outbound connection of an actor passes | 6.2 |
atenet-router | The router: the dataplane pod that receives each request for an actor and forwards it to the worker | 6.1 |
| Atespace | A name that groups actors, templates, and tags, and that carries one access policy. It is a Substrate record and has no link to a Kubernetes namespace. | 3.6 |
| Ballast actor | Our test actor. It holds a set number of MiB of random bytes and reads one byte of each MiB on a request. | 3.3 |
| Binding | One role with the identities that hold it, inside an access policy | 5.2 |
| Bootstrap owner | A principal from the server flag --authz-bootstrap-owners that always holds the global owner role | 5.2 |
| Bucket | The object store location that holds durable snapshots. In our clusters it is ate-snapshots on rustfs. | 8.1 |
| Crashed | The actor state after a sandbox or a worker is lost, or after a failed resume. Only a revert or a delete leaves it. | 3.5 |
| Credential injection | The egress gateway replaces a header of an outbound request with a secret that the actor never holds | 6.5 |
| Credential provider | The service that returns that secret to the egress gateway | 6.5 |
| Dataplane | The router and the egress gateway together, built on Envoy or on agentgateway | 6.6 |
| Default deny | An actor with no matching egress rule reaches no host and resolves no name | 6.2 |
| DNS relay | The resolver inside the sandbox network that answers only for names that the egress policy allows | 6.3 |
| Durable snapshot | A snapshot whose files are in the bucket, with the storage state completed | 3.4 |
| EgressPolicy | The rules of one actor that name the hosts and ports it can reach and the headers that the gateway replaces | 6.2 |
| Epoch | A number that a worker process gets when it starts. Actors from an older epoch of the same worker become crashed. | the API reference |
| Files-only snapshot | A snapshot at volumes fidelity. It keeps the durable directories, and the processes start again at a resume. | 3.4 |
| First call | The first request to a new actor, which includes a resume from the golden snapshot | 3.1 |
| Golden snapshot | The snapshot that Substrate takes of one started actor for each template, so that new actors resume from it | 3.1 |
| Golden tag | The tag under which Substrate stores the golden snapshot of a template | 3.1 |
| gVisor | The sandbox class that runs an actor under a user-space kernel. The API has no default class: each WorkerPool names exactly one. The pool of the counter demo names gVisor. | 4.2 |
| Harness | The runtime that executes an agent and decides when to suspend its actor. In kagent it is also the object that names the WorkerPool and the snapshot location. | 7.1 |
| Ingress allowlist | The list of client identities that the router accepts | 5.1 |
| Installer | The program ate-setup, which installs and removes Agent Substrate from a checkout of the tag | 2.1 |
| Interception CA | The certificate authority whose key signs the certificates that the egress gateway shows to an actor for an https rule | 6.4 |
| kagent | An open-source agent framework for Kubernetes. Version 1.0.0 installs its own distribution of Substrate. | 7.1 |
kubectl ate | The command-line plugin for the Substrate API | 2.2 |
| Lease | A row in Postgres that gives one workflow the right to change one object. The workflow renews it, and it expires 30 seconds after its last renewal. | 8.1 |
| microVM | The sandbox class that runs an actor in a small virtual machine. It needs KVM on the node. | 4.2 |
| Mock model | The server in our kagent cluster that answers model calls with a fixed reply, so that model time is near zero | 7.1 |
| Model gateway | A mode of agentgateway that routes requests to model providers and applies budgets and failover. It is separate from the Substrate dataplane. | 7.2 |
| Node-local snapshot | The copy of a snapshot on the node disk. After an asynchronous suspend it is the only copy until the upload. | 3.4 |
| Object store | The service that holds the bucket: rustfs in our clusters, Google Cloud Storage or S3 in the documentation | 8.1 |
| Parked request | A request that the router holds open while it retries the resume of the actor | 4.4 |
| Parking budget | The time that the router holds a request with no worker before it returns a 503. The default is 5 s. | 4.4 |
| Placeholder header | The header that an actor sends with a value that is not a secret, which asks the gateway for the swap | 6.5 |
| Placement | The choice of a worker for an actor that resumes | 4.3 |
| Plugin | The binary kubectl-ate, which kubectl runs for each command that starts with kubectl ate | 2.2 |
| Preview feature | A feature that is off unless the server flag --preview and the client header both allow it | the flags reference |
| Principal | The identity that ate-api-server derives from a credential, and the name that access policies grant roles to | 5.1 |
| Proxy | kubectl ate proxy actor: a local listener that forwards TCP to one actor through the router | 2.3 |
| Request parking | The router holds a request and tries again while no worker has room | 4.4 |
| Resume | Restore an actor from its latest snapshot on a worker. The router asks for it on every request. | 3.3 |
ResumeActor | The call that places a suspended actor on a worker and restores its newest snapshot | 3.3 |
| Revert | Return an actor to its last durable snapshot and discard what came after it | 3.5 |
RevertActor | The call that discards the current run of an actor and returns it to suspended at its last completed durable snapshot | 3.5 |
| Routing header | The HTTP header ate-target-actor, with the value <atespace>/<actor>, which tells the router where a request goes | 6.1 |
| Sandbox | The isolated environment that a worker starts for one actor | 4.2 |
| Sandbox class | The kind of sandbox: gvisor or microvm | 4.2 |
| SandboxConfig | The Kubernetes object that names the runtime versions of one sandbox class and their files | 4.2 |
| Session | In kagent, one conversation. kagent maps each session to one actor. | 7.1 |
| Shared identity | The ServiceAccount ate-client in ate-system, which kubectl ate uses when you give no token | 5.1 |
| Snapshot | The saved state of an actor at one suspend | 3.4 |
| Snapshot cache | A directory on the node that keeps downloaded snapshot files for later resumes | 8.1 |
| Snapshot fidelity | How much a snapshot holds: volumes only, the root filesystem too, or process memory too | 3.4 |
| SPIFFE ID | The identity in a client certificate, such as spiffe://cluster.local/ns/ate-system/sa/atenet-client | 5.1 |
| Standing port-forward | One kubectl port-forward that you keep open, so that parallel CLI calls do not each open their own | 7.1 |
| Suspend | Write a snapshot of a running actor and release its worker | 3.3 |
SuspendActor | The call that checkpoints a running actor, stops its sandbox, and frees its place on the worker | 3.3 |
| Synchronous suspend | A suspend that returns after the snapshot is in the bucket | 3.3 |
| Tag | A named copy of the snapshot of a suspended actor, from which new actors can start | 3.4, the API reference |
| TLS interception | The egress gateway ends the TLS connection of the actor and opens its own to the host, so that it can read the request | 6.4 |
| TLS pass-through | The egress gateway forwards the TLS connection unread, after it checks the server name | 6.4 |
| Trust bundle | The file that holds the interception CA certificate, so that the actor can verify intercepted connections. A sandbox has it only when its template mounts a systemInfo volume with a trustBundle source. The code in the actor must also be set to trust it. | 6.4 |
| Turn | In kagent, one user message and the reply to it. kagent resumes the actor before the turn and suspends it after. | 7.1 |
| Wakeup probe | An HTTP check in a template that a resume waits for before the actor counts as running | 3.1 |
| Warm call | A request to an actor that is already running | 3.3 |
| Worker | One pod of a WorkerPool that registered with the API. It hosts actors up to its capacity. | 4.1 |
| Worker selector | The labels in a template or an actor that choose which pools can host it | 4.3 |
| WorkerPool | The Kubernetes object that asks for a number of worker pods with one image and one resource size | 4.1 |
Sources
The manual ranks its sources by authority, with our own recorded runs first, and this page lists each kind and what the sections take from it.
The ranks match how this manual was made. When two sources disagree, the higher one wins and the section says so. Every source was read or recorded on 2026-11-12, one week after the release of Agent Substrate v1.0.0.
The ranked sources
| Rank | Source | What the manual takes from it |
|---|---|---|
| 1 | our recorded runs on six clusters on four machines: five with Agent Substrate v1.0.0 and one with the Substrate distribution of kagent v1.0.0. Two of the six ran agentgateway v1.7.0 | every listing, and every number in a paragraph that starts with "We measured" |
| 2 | the Agent Substrate source code at the release tag v1.0.0 | every default, state, flag, and mechanism, and the reference tables |
| 3 | the Agent Substrate documentation at the same tag | install steps, the upgrade order, and documented behaviour that we did not run |
| 4 | Agent Substrate issues and pull requests on GitHub | known failures, limits, and the history of a feature |
| 5 | the kagent source code at its tag v1.0.0 | how a session becomes an actor, and the egress policy that kagent writes |
| 6 | the agentgateway source code at its tag v1.7.0 | the Substrate policies of the agentgateway dataplane |
| 7 | the Agent Executor code and documentation at one commit, built and run with no model and then with a small open model on CPU | how a task becomes an actor in 7.5 |
Our runs rank above the code on purpose. The code says what the system is built to do. A run says what it did on a real cluster, with the timers, the images, and the installer of the release. Where a run contradicts the documentation, the section reports both.
Rank 1: our recorded runs
Twenty-four experiments, each with its scripts and raw output in the capture folder. How this manual was made describes the machines and the method.
| Experiment | What it recorded | Used in |
|---|---|---|
| A, the quickstart from source | each install step with its seconds, and the buildx failure | Part 2, 8.3 |
| B, the lifecycle | 900 s with no request, then suspend, resume, crash, and revert | Part 1, Part 3 |
| C, resume against memory | resume and suspend times for actors that hold 0 to 1,000 MiB, from the node and from the bucket, and the disk use | Part 3, 8.1 |
| D, durability | the snapshot timers, a lost node copy, an atelet restart, and a node restart | Part 3, 8.2 |
| E, dependency loss | one request each second while Postgres, ate-api-server, and the Kubernetes API server were away | 1.2, 8.2 |
| F, egress | default deny, allowed hosts, interception, pass-through, and credential injection on both dataplanes | Part 6 |
| G, the dataplanes | the install from release images, and the same inventory for Envoy and for agentgateway | Part 2, Part 6, 7.2 |
| I, capacity | a full pool, and the request parking demo | Part 4 |
| J, death in a sandbox | a process that exits, and one that the kernel kills for memory | 3.5, 8.3 |
| K, kagent | the documented kagent install, 98 turns, parallel turns, and what a files-only snapshot keeps | 7.1 |
| L, the gaps | the microVM class on the first guest, node stops on a cluster with two worker nodes on the second guest (16 vCPU, 23 GB), and worker recovery after a node restart | 4.2, 4.3, 3.5, 8.2 |
| M, Apple silicon | the install from release images and three actor sizes on a laptop | Part 2, Part 3 |
| N, the demos | an idle suspender, a stateful agent, a tool sandbox, and a failure drill, on their own install on the second guest with 16 vCPU and 31 GB | Part 9 |
| P, a node that stops | 13 cases on the cluster with two worker nodes: reverts during and after the outage, a rescue from a tag, an async-suspended actor, a running actor, and the return of the node | 3.4, 3.5, 4.3, 8.2 |
| Q, network checks | eight checks on the second guest (16 vCPU, 23 GB): dependency loss on agentgateway, a reused connection after a rule removal, a model gateway behind egress over TLS, the egress log level, a suspend as a cut-off, a changed Secret, the router ports, and the CPU limit | 4.1, Part 6, 7.2, 8.2 |
| R, Agent Executor | the install of its head commit on Agent Substrate v1.0.0, what each of its objects becomes, lifecycle times, and where its documents and the run disagree | 7.5 |
| S, a real model | a small open model on CPU, kagent turns with it, and five Agent Executor follow-ups: a model call, the example agent image, a clone that succeeds, worker loss under a task, and release v0.3.1 | 7.1, 7.5 |
| T, a drain and a bucket outside the cluster | five drains of one worker node, a node stop with the other node full, and a bucket outside the cluster with added delay and with an outage, on the second guest (16 vCPU, 23 GB) | 4.3, 8.1, 8.2 |
| U, Postgres, upgrade, and scale | an install with a Postgres outside the cluster and its outage, one upgrade from v1.0.0-rc.3 to v1.0.0, and bursts of resumes on one node with up to 10,750 actor rows, on the first guest | 1.1, 4.5, 8.1, 8.4 |
| X, a bucket and a Postgres on another machine | bucket cycles with TLS, with a rate limit, and with added delay, two bucket outages with actors that hold data, an install with Postgres on a second guest, and two outages of that database, on the second guest (16 vCPU, 23 GB) | 4.4, 8.1, 8.2, 8.3 |
| W, bounds and open commands | two settings that bound a kagent turn, and Agent Executor again: a worker loss with a command open, the skills and MCP servers of a workspace, the example with its own prompt and repository, and second runs of the clone and of the agent image | 7.1, 7.5 |
| Y, second runs, a rollback, and v0.6.0 | three outages of the bundled Postgres and one each of ate-api-server and the Kubernetes API server, a second upgrade from v1.0.0-rc.3 to v1.0.0 and a rollback, and an install of v0.6.0 with a direct upgrade, a reinstall, and Agent Executor v0.3.1, on the first guest | 4.3, 7.5, 8.2, 8.4 |
| Z, bursts on one node | a pool grown to 300 workers in steps of 50, bursts of 150, 200, and 300 resumes, the restore time inside each worker, the CPU of each component, and 1 to 4 replicas of ate-api-server and of the router, on the first guest | 1.1, 4.5, 8.2, 8.3 |
| V, validation | twelve checks on the first guest: memory that holds no data, a Python runtime, resumes at the same instant, fidelity, templates, access policies, actor JWTs, a preview feature, a process exit, and a suspend during a request | Part 3, 4.4, 4.5, Part 5, 8.5 |
Rank 2: the code
| Source | Used in |
|---|---|
Agent Substrate, tag v1.0.0, commit b34fbee742906494b5eb4f1f36987176bf8770a4, released 2026-11-05, github.com/agent-substrate/substrate | every section |
The same repository at tag v0.1.0, for the comparison of old and new instructions | 8.4 |
kagent, tag v1.0.0, released 2026-11-09, github.com/kagent-dev/kagent | 7.1, 8.1 |
agentgateway, tag v1.7.0, github.com/agentgateway/agentgateway | 6.6, 7.2 |
The manual reads seven parts of the Substrate code most. They are the API definition, the lifecycle workflows of the API server, the snapshot tracker of atelet, and the request parking code of the router. The others are the database migration, the install manifests, and the command-line plugin. The manual source records the files that each section used, and the audit checks that record.
Rank 3: documentation at the tag
These pages of the Agent Substrate documentation are cited by their titles. We checked each statement that we repeat against the code, and against a run where we had one.
The project site, ate.dev, serves the same pages at version 1.0. We read all 48 pages of the site on 2026-11-12 and compared the 38 that have a file at the tag with that file. We found differences in rendering only, such as link text and placeholders, and no difference in content. The first page of the site is a source of its own, because it states results with no conditions. What Substrate does, and what it leaves to you sets each of those statements beside our runs.
| Page | Used in |
|---|---|
| the quickstart | Part 2, 8.4 |
| the architecture page | Part 1 |
| the API guide | Part 3, Part 4, 8.1 |
| the resource model and the glossary | Part 3, the glossary |
| the request parking page | 4.4 |
| the sandbox classes page | 4.2 |
| the guides for authentication, authorization, and actor JWTs | Part 5 |
| the guides for network egress, the trust bundle, and credential injection | Part 6 |
| the PostgreSQL guide and the node storage guide | 8.1 |
| the upgrade runbook and the version skew policy | 8.4 |
| the page on preview features | 8.5, the flags reference |
| the key rotation guide for actor JWTs | 8.5 |
| the installer command reference and the operator install guide | Part 2, the flags reference |
the kubectl ate reference | 2.2, the command reference |
Three statements in these pages do not match the release. The quickstart says that Substrate suspends an idle actor, and our run and the code show that it does not. The upgrade runbook gives an example version that does not exist. The help text of one installer flag says that a credential provider requires Envoy, and the provider worked with agentgateway.
Rank 4: issues
The issues are reports by users and maintainers. We name an issue where it explains a limit that we did not run, and we say so in the text.
| Issue | What it reports | Used in |
|---|---|---|
| #3101 | node-local snapshots filled a node disk, and the kubelet evicted the workers | 8.1 |
| #3775, #2972 | the design for detecting a dead process inside a living sandbox | 8.5 |
| #3441 | a proposal to reserve less than the declared limit for each actor | 8.5 |
| #3589 | ingress authorization is the same for every atespace | 8.5 |
| #3203 | a proposal for other storage backends, which the maintainers declined before the release | 8.5 |
| #3602 | a durable directory has no size limit | 8.5 |
| #3347 | the reuse of a name is not safe | 8.5 |
| #3285 | a change of egress policy does not close open connections, which our run confirmed | 6.6, 8.5 |
Other sections cite more issues, and each names them in its own text.
The project marks in the figures belong to their owners, and we use them only to identify the projects.
Index of figures
Every figure of this manual, in order, by the one claim that its caption makes.
Each row links to its figure, and the claim is the claim: field of the figure block. A measured number in a figure comes from our recorded runs, and the chart figures read the summary files directly. Two charts are exceptions: figure 1.2b and figure 6.6 read their recordings directly. The colours follow the palette of the front matter:
- blue is the Substrate control plane:
ate-api-server,ate-controller, andate-idp-server - violet is workers and sandboxes:
atelet, worker pods, gVisor, and microVM - green is the actor, the workload that you run
- plum is the agent harness and its model calls: kagent and the model
- olive is the client and hosts outside the cluster
- indigo is the dataplane:
atenet-router,atenet-egress, Envoy, and agentgateway - amber is a lifecycle state or a step between two states
- teal is durable state: Postgres, the object store, and snapshots
- rose is a failure: a crashed actor, a 500, a 503, or lost calls
- grey is outside Substrate: Kubernetes, the node, and the operator
| Fig. | The claim it makes |
|---|---|
| 0.1 | Each arrow style marks one kind of exchange, and every label is a real name, message, or number from our recorded runs. |
| 1.1 | Substrate resumes an actor onto a worker when a request arrives, and the call that suspends it again must come from you. |
| 1.2 | The first request to a suspended actor makes the router call ResumeActor, and ate-api-server restores the actor before the request reaches it. |
| 1.3 | Only the first request paid for a resume, and the request after 900 idle seconds was as fast as the others because the actor never stopped. |
| 2.1 | From the release images deploy ate-system took 162 s on one guest, and from source on a larger guest it failed, then took 266 s. |
| 2.2 | kubectl ate reads atespaces, templates, actors, and workers from Postgres through ate-api-server, and kubectl reads only three custom resources from etcd. |
| 2.3 | With kube-apiserver down for 86 s the proxy served no request, and a pod that called the router served every one. |
| 2.4 | The install put the control plane, the dataplane, the node agent, Postgres, and the object store in one namespace on one node. |
| 2.5 | In ate-system only ate-api-server and ate-idp-server run two pods, and every other workload, the router and Postgres among them, runs one. |
| 3.1 | Substrate builds one golden snapshot for each template, and an actor created after that starts suspended from it. |
| 3.2 | At 1,000 MiB the first call of a new actor took 3.5 to 12.7 seconds, and a later resume took 2.2 seconds. |
| 3.3 | An actor leaves ACTOR_STATE_CRASHED only through RevertActor or DeleteActor, and no request can resume it. |
| 3.4 | At 1,000 MiB a synchronous suspend took 6.4 seconds, and the default asynchronous suspend took 2.0 seconds. |
| 3.5 | A resume from the node took 2.2 seconds at 1,000 MiB, and a resume from the object store took 4.7 seconds. |
| 3.6 | Only SNAPSHOT_FIDELITY_MEMORY keeps the processes, and the two lower levels start new processes on every resume. |
| 3.7 | An actor that held 500 MiB of zero bytes had a 4.6 MiB snapshot, and the same actor with random bytes had 504.1 MiB. |
| 3.8 | 25 suspended actors that held 8,750 MiB of memory used 8,801 MiB on the node and 10,824 MiB in the object store. |
| 3.9 | For the first five minutes after an asynchronous suspend, the only copy of the new snapshot is on one node. |
| 3.10 | The text of the error tells you the state of the actor, and the state tells you the one call that helps. |
| 3.11 | An atespace holds templates, actors, and tags, a namespace holds worker pools, and only a label selector joins them. |
| 4.1 | A WorkerPool becomes a Deployment of worker pods, and each pod becomes a Worker record that reports the limits of the pod as capacity. |
| 4.2 | An actor used 0.25, 0.50, and 1.0 host cores at limits of 250, 500, and 1000 millicores, and it needed four threads to use 2.0 cores at 2000. |
| 4.3 | A gVisor actor shares the node kernel through a user-space kernel, and a microVM actor runs on its own guest kernel under KVM. |
| 4.4 | At 0 MiB a microVM resume took 0.33 seconds against 0.22 for gVisor, and at 500 MiB it took 0.80 against 1.07. |
| 4.5 | A worker must pass six checks before it is a candidate, and the scheduler then takes the less loaded of two random candidates. |
| 4.6 | A suspended actor with a complete snapshot stayed in RESUMING for 5 min 33 s from its first request, on a stopped node, then became CRASHED. |
| 4.7 | A parked request is served when a worker becomes free inside the 5 second budget, and it gets a 503 when none does. |
| 4.8 | Sixteen resumes at the same instant took 0.37 seconds each at the median, against 0.23 seconds for one resume alone. |
| 4.9 | Two workers with a 2 CPU limit ran four one-CPU actors, and one worker with no limits ran sixteen on a node with 16 vCPUs. |
| 4.10 | One node finished 32 to 39 resumes each second from 50 to 300 at one time, so the wait grew with the size of the burst. |
| 4.11 | Inside a burst about 100 restores ran at once, and each one took about ten times as long as a restore alone. |
| 5.1 | The API and the router check a client separately: the shared ate-client token owns every actor, and the router serves any identity on its one list. |
| 5.2 | A role on the global scope holds in every atespace, and a role on one atespace holds only there. |
| 5.3 | The egress gateway mints the token and replaces a header with it, so the service receives an identity that the actor did not send. |
| 6.1 | The router calls ResumeActor for every request, also when the actor already runs, and only then opens a tunnel to the worker. |
| 6.2 | Both dataplanes refuse a caller with no certificate or the wrong SPIFFE ID in the TLS layer, so the caller gets no HTTP status. |
| 6.3 | A connection from an actor passes two checks, the DNS relay for the name and atenet-egress for the request, and each one reads the same policy. |
| 6.4 | A short Service name passes the DNS relay through the search list, and the gateway then refuses it because the Host header is not in the policy. |
| 6.5 | In our polled runs a new rule reached DNS within 3 seconds, and in one unpolled run on Envoy the name stayed unresolved for 16 seconds. |
| 6.6 | With an https rule the actor holds a session with atenet-egress, and with a tlsPassthrough rule it holds a session with the real host. |
| 6.7 | The secret travels from the Kubernetes Secret to atenet-egress and on to the upstream, and it enters the actor only if the upstream sends it back. |
| 6.8 | A changed Secret reached the upstream in 1.0 to 1.6 s on Envoy and in 100.6 to 280.5 s on agentgateway. |
| 6.9 | The Envoy dataplane spreads its work over five containers and 91 MB, and agentgateway uses two containers and 38 MB with the CA key inside one. |
| 6.10 | Both dataplanes refused new requests and requests on a reused connection within 9 seconds, and neither closed an open response or pass-through connection. |
| 7.1 | Every turn resumes the Session actor through the router, and kagent calls SuspendActor as soon as the task completes. |
| 7.2 | A turn that follows the last one with no pause costs the same as a turn 4 s later, because every turn resumes the actor. |
| 7.3 | The model was about nine tenths of every clean turn, and one turn of 50 did not end. |
| 7.4 | Sixteen of 24 parallel turns ended within 5.45 s, and the other eight waited for a free CPU and ended up to 2.4 s later. |
| 7.5 | In places A and B agentgateway enforces the Substrate policy, and only in place C does it read model and tool traffic. |
| 7.6 | The installer creates cloud resources in your project and installs the same control plane in your cluster that ate-setup installs on kind. |
| 7.7 | Substrate fits only when all five answers are yes, and each no names a simpler choice that fits better. |
| 7.8 | One task became one actor and one template with a files-only snapshot, and no object opened egress or chose a pool. |
| 7.9 | A resume of a prepared task took about 0.24 s, and a resume whose clone failed took more than 10 s because the runner tried five times. |
| 7.10 | The resume after a worker loss took 0.3 s in both runs, and it kept the saved file only when the snapshot had reached the object store. |
| 7.11 | An open command ended with an error within 0.2 s of the delete, and nothing started it again. |
| 8.1 | Postgres holds the record of each actor, the bucket holds its durable snapshot, and the node disk holds a second copy for 30 minutes. |
| 8.2 | With 20 ms of added round trip, a resume of 1,000 MiB from the bucket took 10.2 s and not 4.8 s. |
| 8.3 | With the bucket on another guest, a request to an actor of 1,000 MiB got a 504 after 10 s and its first 200 after 12 s. |
| 8.4 | Requests failed without Postgres and without ate-api-server, and every request from inside the cluster succeeded without the Kubernetes API server. |
| 8.5 | Without Postgres a failed request took the full 9 s of the client timeout, and without ate-api-server it took 5 s at most. |
| 8.6 | A running actor answered again 3 s after Postgres was ready on Envoy and 21 s after it on agentgateway, in one run each. |
| 8.7 | The Kubernetes API returned after 8 s and all pods after 147 s, and the actor needed a revert and 78 more seconds. |
| 8.8 | A tag made before the stop gave a working actor in 1.2 s, and every other path waits for the workers or for the node. |
| 8.9 | A drain crashed each running actor and did not disturb any suspended one. |
| 8.10 | The delay and the body of a failed request separate six causes that share the status codes 500 and 503. |
| 8.11 | Only the worker step ends running actors, and an actor has 30 minutes there only if its process survives SIGTERM. |
| 8.12 | In both runs suspends and resumes failed in the atelet step, and a running actor that exits on SIGTERM crashed when the worker image changed. |
| 8.13 | Substrate performs the lifecycle operations that you request, and your harness or your platform team must decide when to request them. |
| 9.1 | The suspender forwards each request, keeps the time of the last one for each actor, and calls SuspendActor when an actor is quiet. |
| 9.2 | All 140 requests returned 200, and the worker held one actor for 38 of 58 samples in place of the three that had traffic. |
| 9.3 | The conversation returned under both fidelities, and the note, the file under /tmp, and the process itself returned only under memory fidelity. |
| 9.4 | A turn that resumed the actor took 0.30 seconds under memory fidelity and 4.5 to 5.1 seconds under volumes fidelity. |
| 9.5 | One download from the allowed mirror passed, three attempts to reach other hosts failed, and each revert returned an empty work directory. |
| 9.6 | Each of the six failures gave a good answer in under one second after its fix, and only one of them needed two commands. |
| R.1 | A crashed actor returns to service only through RevertActor, and every path into RUNNING goes through ResumeActor. |
How this manual was made
This page records the version, the machines, the lab method, and the capture kit behind every fact in the manual, and what we did not test.
The version
The manual pins Agent Substrate v1.0.0, released on 2026-11-05 at commit b34fbee742906494b5eb4f1f36987176bf8770a4. The integration sections pin kagent v1.0.0 and agentgateway v1.7.0. Every cluster ran Kubernetes 1.37.0 on kind v0.33.0. We recorded the runs and verified the facts on 2026-11-12.
Agent Substrate changed its installer, its API names, and its states in the weeks before 1.0.0. A statement in this manual is about v1.0.0 only. Section 8.4 lists what differs in older text.
Four kinds of evidence
Each fact carries one of four labels, and the sources page ranks them.
We measured: the number comes from one of our recorded runs. The paragraph names the machine and the number of samples. You can rerun the script and compare.
The code: the default or the mechanism comes from the source at the release tag. We read it and did not run it, unless the same paragraph says that we did.
The documentation: the statement comes from the project documentation at the tag. We use it for paths that we did not run, such as an install on GKE, and the text says so.
An issue: a user or a maintainer reported the behaviour in the public tracker. We name the issue and do not present it as our result.
Our runs rank first. Where a run and the documentation disagree, the manual reports the run and names the disagreement. The clearest case is the idle actor. The quickstart says that Substrate suspends it, and our actor read RUNNING in 60 of 60 samples over 900 s.
The four test beds
| Name in the manual | Machine | What ran on it |
|---|---|---|
| an x86 server guest (24 vCPU, 47 GB, nested KVM) | a KVM guest on a shared x86 server, Ubuntu 24.04, Docker 29.1.3, Go 1.27.3 | Agent Substrate built from source, the Envoy dataplane, gVisor: experiments A, B, C, D, I, J, and V. Also the microVM class and the second node restart of experiment L. Last, new kind clusters from the release images for experiments U, Y, and Z, and one built from source at v0.6.0 for Y |
| a second x86 server guest (16 vCPU, 23 GB) | a guest on the same server | Agent Substrate from the release images, Envoy and then agentgateway v1.7.0: experiments E, F, and G. Also a second cluster with three kind nodes for experiment L. Later, at the same time, experiment P on the cluster with three nodes and experiment Q on the cluster with one node. Last, experiment T on the cluster with three nodes |
| a second x86 server guest (16 vCPU, 31 GB) | a guest on the same server | Two clusters, one after the other. kagent v1.0.0 with its own distribution of Substrate, and a mock model: experiment K. Then a separate install of Agent Substrate from the release images, with the Envoy dataplane, for the four demos of Part 9: experiment N. Then Agent Executor at one commit on that second cluster: experiment R. Last, a small open model on CPU in a container on the guest, for kagent and for Agent Executor on the two clusters: experiment S. After a restart of the guest, the same two clusters and the same model: experiment W |
| an Apple silicon laptop under Docker Desktop | 10 CPUs and 8 GB given to Docker | Agent Substrate from the release images, gVisor only: experiment M |
The four are different machines. Two of them have 16 vCPU, so every measured paragraph gives the memory size too. A number from one machine is never a controlled comparison with a number from another.
Three sections show numbers from two machines side by side. Section 2.1 has the two installs. Section 8.2 and section 9.4 have the two Postgres recoveries. Each one names both machines and says that the difference is indicative only.
The lab method
- One guest for each track. Each line of work had its own virtual machine, so that a destructive test in one could not change a number in another. Nothing was installed on the server itself.
- Disks in memory. Each guest kept its disk in the memory of the server. Disk writes were faster than on a cloud volume, so our suspend and upload times are lower limits for a real disk.
- One node, with one exception. Every cluster but one had one kind node. The exception had one control plane node and two worker nodes. It gave the placement and node loss results of section 4.3 and the runbook of section 8.2.
- Two experiments shared a guest. Experiments P and Q ran at the same time on one guest, each on its own cluster. CPU was shared, so read a difference of a few milliseconds in those runs as noise.
- An object store on the same machine. Snapshots went to rustfs on the same node and the same disk. In experiment T the bucket ran outside the cluster, on the same guest, with an emulated delay. In experiment X it ran on a second guest of the same server. No bucket was in a cloud, and the path between the guests is not a real network link.
- Two install paths. We installed once from source, as the quickstart documents, and once from the release images, on two different guests. The two paths gave us the buildx failure and the install times of section 2.1.
- A mock model, and then a small real one. In experiment K a small server in the cluster answered each model call with a fixed reply. Those turn times in section 7.1 hold almost no model time. In experiment S the model was
qwen2.5, an open model of 1 to 2 GB. It ran on 12 CPUs with no GPU, at 7 to 14 tokens per second. Both graphics cards of the server were in use by another team. A model on a GPU or a hosted model gives other model times. - Client-side times. Unless a section says otherwise, a time is what the client saw, through
kubectl ate proxyor from a pod in the cluster. A warm call through the proxy took 0.058 to 0.068 s on the first guest, and that cost is inside every proxy number. - Small samples. Most points have 5 or 10 samples, and some events were recorded once. Each paragraph gives its count. Treat a result from one run as an observation.
The experiments
| Letter | Question | Main result |
|---|---|---|
| A | Does the source quickstart work as written? | Only after docker-buildx was installed. 266 s for the system step. |
| B | Does Substrate suspend an idle actor? What do suspend, resume, crash, and revert return? | It does not. Each operation is in section 3.3 and section 3.5. |
| C | How does resume time grow with the memory that an actor holds? | About 2 ms for each MiB of random bytes from the node, and about twice that from the bucket. |
| D | When is a snapshot safe? | When its durable copy shows COMPLETED, about 5 minutes after the suspend by default. The timer alone is not proof. Before that, losing the node copy crashes the actor on the next request. |
| E | Which requests fail when a dependency is away? | All of them without Postgres or ate-api-server. None without the Kubernetes API server. |
| F | What does an egress policy do on each dataplane? | Default deny, interception, pass-through, and injection work on both, with different error texts. |
| G | How do the two dataplanes differ? | agentgateway uses fewer containers and less memory, and it keeps the interception key in the dataplane container. |
| I | What does a client see when a pool is full? | A 503 after about 5 s, or a served request if a worker becomes free in that time. |
| J | Does Substrate notice a process that dies in a sandbox? | A memory kill in about 4 s. A clean exit never, until something calls suspend. |
| K | How does kagent 1.0 use Substrate? | One actor for each session, resumed and suspended on every turn, 98 of 98. |
| L | How does the microVM class compare with gVisor? What happens on two worker nodes when one stops? How do workers recover after a node restart? | A microVM resume cost about 1 ms for each MiB, against about 2 ms. No actor left a stopped node by itself. After a restart, resumes failed for 10 to 30 s after the control plane returned. |
| M | Does v1.0.0 work on Apple silicon? | Yes for gVisor actors up to 200 MiB. No KVM, so no microVM class. |
| N | Do the patterns of Part 9 work on a new install from the release images? | Four demos ran: an idle suspender, a stateful agent, a tool sandbox, and a failure drill. Sections 9.1 to 9.4 report them. |
| P | What can an operator do when a worker node stops and stays down? | No actor left the node by itself, in 13 cases. A revert helped in one situation, and it left the actor in REVERTING in the others. A tag from before the stop gave a working actor in 1.2 s. The runbook is in section 8.2. |
| Q | Do eight open statements about the network and the CPU limit hold in a run? | The second table below has one row for each. |
| R | Does Agent Executor work on Substrate v1.0.0 as its pages say? | One commit installed and ran tasks with no model. A task stayed RUNNING for 600 s with no request. A task cannot bind a gateway yet, so a workspace clone failed. Section 7.5 reports it. |
| S | What changes with a real model, and do five open statements about Agent Executor hold in a run? | With a small model on CPU, the model was about nine tenths of a kagent turn, and one turn of 50 did not end. The third table below has one row for each Agent Executor item. |
| T | What do a drain, a full node, and a bucket outside the cluster do to an actor? | A drain crashes a running actor and does not disturb a suspended one. The fourth table below has one row for each item. |
| U | Do an outside Postgres, an upgrade, and a larger count of actors work as the project says? | The install needed one fix, the upgrade crashed the running actor, and one node finished about 37 resumes each second. The fourth table below has the rows. |
| V | Do twelve statements that the first draft marked as not tested hold in a run? | The first table below has one row for each. |
| W | Can a kagent turn be bounded, and do four more open statements about Agent Executor hold in a run? | Two settings of the ModelConfig bound a turn. The fifth table below has one row for each item. |
| X | What do a bucket and a Postgres on another machine cost, and what happens when each is away? | A resume that passes 10 s returns 504 first, and a later request succeeds. One running actor stayed at 504 after a database outage until the router restarted. The fourth table below has the rows. |
| Y | Do the outages and the upgrade repeat, and do a rollback and an upgrade from v0.6.0 work? | The outages and the upgrade repeated, and the rollback crashed the same actor as the upgrade. Nothing made on v0.6.0 survived. The sixth table below has the rows. |
| Z | Do bursts above 150 resumes work on one node, and what sets the rate? | Up to 300 at one time, at 32 to 39 each second. The restores inside the gVisor workers set the rate, and the control plane did not. The seventh table below has the rows. |
The letters H and O are not in the table. H was a single check that a plain scratch image works as an actor, and we did not use the letter O. Experiment L closed three gaps of the earlier runs, and the sections of Part 4 report it.
Experiment V ran on the first guest, with a pool of two workers that hold 16 one-CPU actors. It took twelve sentences of the draft that said we did not test something, and it tested each one.
| Item of V | Question | Result | Section |
|---|---|---|---|
| 1 | Does a snapshot shrink when the memory holds no data? | Yes. 500 MiB of zero bytes gave a 4.6 MiB snapshot and a 0.23 s resume. Size and resume time follow the memory that holds data. | 3.4, 3.3 |
| 2 | Does the resume rule hold for an ordinary runtime? | A Python server with a peak of 245 MiB resumed in 0.41 s. Its golden snapshot took 39.3 s to build. | 3.3, 3.1 |
| 3 | What do resumes at the same instant cost? | 0.37 s each at 16 at once, against 0.23 s alone, 155 of 155 served. A burst of 32 on 14 free places: 14 served, 18 refused after 5.1 s. | 4.4, 4.5 |
| 4 | What does each of the three fidelities keep? | The root filesystem level keeps files and starts new processes. Both lower levels resume as a cold start. | 3.4 |
| 5 | What happens to an actor created before the golden snapshot exists? | It has no snapshot, its first call is a cold start, and it never adopts the golden snapshot. | 3.1 |
| 6 | Can an actor move to another template? | Only when suspended. The volumes continue, and the memory and the root filesystem start new. | 3.1 |
| 7 | Can a template use a pool in another namespace? | Yes. A selector that matches no pool is accepted and looks like a full pool. | 3.6 |
| 8 | Do access policies and token files work as documented? | Yes. Each role was refused where the documentation says, and a change took effect within 2 s. | 5.1, 5.2 |
| 9 | What is in an actor JWT? | The documented claims, signed with ES256. The gateway used one token for several requests and across a suspend. | 5.3 |
| 10 | What does a preview feature need? | The server flag and the client flag. With either one missing, the error names the field only. | 8.5, R.5 |
| 11 | Does Substrate ever find a process that exited? | Not in 20 minutes. The dead actor kept its CPU booking until a suspend marked it crashed. | 3.5 |
| 12 | Does a suspend wait for an open request? | No. The request got a 503 after 2.2 s, and the suspend reported success. | 3.3 |
The first run of item 3 used wrong port numbers for 1 to 8 resumes, and we discarded it. The table of section 4.4 is the second run. The bursts of 32 are from the first run. The first try of item 9 failed because the actor asked for the host name before the policy existed, and section 5.3 reports it.
Experiments P and Q ran after V, on the second guest (16 vCPU, 23 GB). Each node-stop case of P ran once. Q took eight statements that the draft had from the code only, or that it marked as not tested. It ran each one on both dataplanes where the dataplane matters.
| Item of Q | Question | Result | Section |
|---|---|---|---|
| g | Do the three dependency outages give the same result on agentgateway? | Yes. Every failure body is ingress failed, and a running actor answered again 21 s after Postgres was ready, against 3 s on Envoy. | 8.2 |
| h | Does a reused connection keep a removed rule alive? | No. The next request got 403, about 1 s later on agentgateway and 5 to 9 s later on Envoy. | 6.6 |
| i | Does a model gateway work behind an egress rule, over TLS? | Yes, and the gateway selects the model. A private CA failed until we replaced the root file of the egress container. A new intercepted connection cost about 0.6 s. | 7.2, 6.4 |
| j | Does a lower log level remove header values from the agentgateway egress log? | Yes. RUST_LOG=info gave 100 lines for 100 requests and no header value. The installer sets debug again. | 6.6 |
| k | Does a suspend cut an open connection? | Yes, in 1.3 to 2.2 s, and the connection does not come back after a resume. | 6.6 |
| l | How soon does a changed Secret reach the upstream? | After 1.0 to 1.6 s on Envoy, and after 100.6 to 280.5 s on agentgateway. No request failed. | 6.5 |
| m | Which ports does the router have on each dataplane? | 443 and 8444 need a client certificate on both. agentgateway also has a stats port that needs no credentials, and two Service ports with no listener. | 6.1 |
| n | Is the CPU limit of an actor a cap? | Yes, a hard cap from a cgroup for each actor. The actor sees 2 CPUs at every limit, and CPU time read inside it is wrong under the cap. | 4.1 |
The first test of item n stopped each run when the CPU time inside the sandbox reached 10 s. That CPU time is not valid under a cap. We kept the file as the record of the mistake, and we ran the test again with a fixed amount of work. The clean-up of experiment P deleted the golden tags of two templates, and section 3.4 reports what that broke.
Experiment S ran after P and Q, on the guest with 31 GB. It took the five things that section 7.5 listed as not run, and it replaced the mock model of experiment K with a real one.
| Item of S | Question | Result | Section |
|---|---|---|---|
| kagent | What does a turn cost with a real model? | 11 to 17 s at the median with a small model on CPU, of which resume and suspend were about 0.7 s. One turn of 50 did not end, and nothing bounded it before the client timeout at 300 s. | 7.1 |
| model | Can a task call a model through a Model object? | No. No field of a task names a Model. With an http rule set on the actor, 20 of 20 calls returned 200. | 7.5 |
| agent | Does the example agent image run? | Yes, in one run with one short prompt, without a vendor key. The task stayed Running after the agent ended. | 7.5 |
| clone | Can a workspace clone? | Not through Agent Executor alone. With a tlsPassthrough rule on port 443 set on the actor, the clone worked in a resume of 9.57 s. An https rule failed on the certificate. | 7.5 |
| loss | What happens when a worker goes away under a task? | The actor is CRASHED in about 1 s. ax resume task repaired it in 0.3 s. A file that a suspend had saved 8.7 s before the loss was gone. | 7.5 |
| release | Does the release v0.3.1 run on Substrate v1.0.0? | No. It calls a gRPC service name that v1.0.0 does not serve. | 7.5 |
Experiments T and U ran after S, at the same time on two guests. T ran on the cluster with two worker nodes on the second guest (16 vCPU, 23 GB). U ran on the first guest, on new kind clusters from the release images. They took six things that this page listed as not tested. Each case, each outage, and each upgrade step ran once.
| Item | Question | Result | Section |
|---|---|---|---|
| T, drain | What does kubectl drain do to an actor? | It crashed 2 of 2 running actors, and one revert repaired each. 3 of 3 suspended actors resumed on the other node with no command. | 8.2 |
| T, full node | Does the runbook work when the other node has no free worker? | The revert and the tag rescue were accepted, and requests got 503 after 5 s until a worker was free. | 8.2, 4.3 |
| T, bucket | What does a bucket outside the cluster cost, and what happens when it is away? | With no added delay, little. With 20 ms of round trip, a resume of 1,000 MiB took 10.2 s. With the bucket away, a synchronous suspend crashed a running actor. The bucket ran on the same guest, with an emulated delay. | 8.1, 8.2 |
| U, Postgres | Does an install work with a Postgres outside the cluster? | Yes, on the third try. The documented database script fails unless it runs in one transaction. An outage looked the same as with the bundled database. | 8.1 |
| U, upgrade | What does a client see during an upgrade? | From v1.0.0-rc.3 to v1.0.0: 24 s of failed suspends and resumes in the atelet step, and a crashed running actor in the worker step. No older release has images. | 8.4 |
| U, scale | Do the scale statements of the project site hold on one node? | A single resume stayed at 0.36 s up to 10,750 actor rows. One node finished 36 to 39 resumes each second. The ramp stopped at the disk of the guest. | 4.5, 1.1 |
| X, bucket | What does a bucket on another machine cost, with TLS, a rate limit, and delay? | With no shaping, 200 MiB resumed in 2.64 s and 1,000 MiB in 12.15 s. In 26 of 51 cycles the first answer was a 504 after 10 s, and a later request returned 200. At 20 Mbit/s, 200 MiB took 88 s. TLS needed a patch. | 8.1, 4.4 |
| X, bucket outage | Does the bucket outage look the same with data in memory? | Yes. A synchronous suspend crashed the running actor and lost 2 calls, in 2 of 2 cases. A default suspend lost nothing. | 8.2 |
| X, Postgres | Does an install work with Postgres on another machine, and what does an outage do? | Yes. 20 ms of round trip added about 0.5 s to a suspend and to a resume. After 1 of 2 outages, one running actor returned 504 until the router restarted. | 8.1, 8.2 |
The first attempt at two cases of T sent its requests to the wrong port, and we ran both again. One pass of the bucket outage has rows with a client error, and the summary leaves those rows out. The capture kit names each file that we did not use.
Experiment X ran after a restart of the guest with 23 GB, at the same time as W. The bucket and the database ran in containers on the guest with 31 GB. The two guests reach each other through the user-mode network of the hypervisor, at about 100 MiB each second. The first attempt at the bucket cycles sent a revert after a 504, and we ran the cycles again without it.
Experiment W ran on the guest with 31 GB, after a restart of that guest. It took four things that this page and section 7.5 listed as not run. The models were the same two small models on CPU.
| Item of W | Question | Result | Section |
|---|---|---|---|
| bound | Does a setting of kagent bound a turn? | Yes, two on the ModelConfig. With maxTokens: 128 the turn completed with a cut answer, in 3 of 3 turns. With timeout: 20 the turn failed after about 21 s and the actor was suspended, in 3 of 3. A client timeout alone left the actor RUNNING and the model busy for 10 minutes. | 7.1 |
| open command | What happens to an open ax ssh command when the worker goes away? | It ended with an error within 0.2 s, in 2 of 2 runs, and nothing sent it again. ax resume task repaired the task. | 7.5 |
| skills, MCP | What reaches the agent from the skills and MCP fields of a workspace? | An empty skills directory. Two of four MCP servers, as a flag of the agent command. The agent reached an HTTP MCP server in 3 of 3 tasks, with an egress rule that we set on the actor. | 7.5 |
| example | Does the documented example run with its own prompt and repository? | Yes, after two fixes: a smaller memory limit, and a delete, because a task cannot be changed. The 3b model then wrote a report that was false. | 7.5 |
| second runs | Do two results of S with one run repeat? | The clone did, in 5.47 s. The agent image did not write its file in the second run, so 1 of 2 runs did. | 7.5 |
In W, the first two tries at the worker loss stopped before the delete, because the task was not on the pool of the test. We ran both again. The first try at the example stopped at a failed resume that we did not explain. The capture kit names each file that we left out.
Experiment Y ran after W and X, on the first guest (24 vCPU, 47 GB), on new kind clusters. It took five things that this page listed as not tested. Each outage, upgrade, and rollback ran once.
| Item of Y | Question | Result | Section |
|---|---|---|---|
| outages | Do the three outages repeat? | Yes. In three outages of the bundled database, the last failed resume was sent 24 to 26 s after it was ready, and the failure text differed in one run. The running actor that stayed at 504 in X did not come back. Without ate-api-server the 503 came in 3 to 5 s again, and running actors served every request without the Kubernetes API server. | 8.2 |
| upgrade | Does the upgrade repeat? | Yes. The atelet step failed suspends and resumes for 34 s, and the actor that exits on SIGTERM crashed in the worker step. An actor in a suspend loop also crashed there, and it stayed crashed until a revert. | 8.4 |
| rollback | Does a rollback in the reverse order work? | Yes, with the same crash in the worker step. Each suspended actor kept its count. | 8.4 |
| v0.6.0 | Can a v0.6.0 cluster move to v1.0.0? | No. The installer stops on the stored version of a CRD. A reinstall on the same cluster needed two fixes and kept no actor. | 8.4 |
| release v0.3.1 | Does the release run on Substrate v0.6.0? | No. It creates an actor with no template, and v0.6.0 refuses it. | 7.5 |
In Y, the resumes of the Kubernetes API outage are not valid, because running actors from the earlier runs had filled the pool. The bursts of 200 to 500 resumes in Y gave no data, because the kube-apiserver of that cluster stopped with 443 of 520 workers up.
Experiment Z ran last, on the first guest (24 vCPU, 47 GB), on a new kind cluster with a limit of 700 pods. It took the bursts that Y could not run, with the pool grown 50 workers at a time to 300.
| Item of Z | Question | Result | Section |
|---|---|---|---|
| pool | Does one node hold 300 workers? | Yes. Each step of 50 was ready in 17 to 21 s, and the Kubernetes API answered in 105 to 108 ms at every step. | 4.5 |
| bursts | What do 150, 200, and 300 resumes at one time cost? | Medians of 3.2 to 3.6 s, 4.3 to 4.6 s, and 7.3 to 7.4 s, at 32 to 39 each second. 4 of 2,800 requests got no answer in 90 s. | 4.5, 1.1 |
| limit | What sets about 37 resumes each second? | Not ate-api-server, the router, Postgres, the client, plain CPU, or disk wait. About 100 restores ran at once inside the gVisor workers, each about ten times as slow as alone. | 4.5 |
| replicas | Do more replicas of ate-api-server help? | No. Four replicas ran the bundled Postgres out of its 100 connections, failed 36 of 300 requests, and crashed 8 actors. | 4.5, 8.3 |
In Z, the first count of workers used the wrong filter and read 0, and we grew the pool again. The upload of 300 snapshots filled the guest disk fast, so we deleted 150 actors and ran 200 and 300 at one time twice each. Before the router test we deleted one actor that we wrongly thought had stayed in ACTOR_STATE_RESUMING. Its two later requests got 404, and the capture kit marks them.
The capture kit
The capture kit holds the recordings and one script. Agent Substrate needs a Kubernetes cluster, so the recordings cannot run in the repository CI. We made them on our machines and stored the output in the lab folder.
cd manuals/agent-substrate-101
python3 capture/run.py
python3 capture/run.py --checkThe first command reads the recordings and writes the summary files, which are the only input of the chart figures. The second command builds the summaries again in a temporary folder and compares them with the stored ones. It needs no network and no keys. It prints one skipped line for the recordings, because it does not start a cluster.
A large log is stored as an excerpt, in which each line is a complete line of the original. A list in the lab folder names each file that was left out or cut, with the reason. The credential values in the egress and network recordings are test values that we made up.
Record it again on your cluster
- Create a kind cluster with the cluster script from the Agent Substrate checkout at v1.0.0.
- Install with
ate-setup, as section 2.1 shows. - Run the script of one experiment from the scripts folder. Each script writes to a folder with the name of its experiment.
- Copy the output over the same folder in the lab folder, then run
python3 capture/run.pyfrommanuals/agent-substrate-101/.
Your seconds will differ from ours. The states, the error texts, and the order of events must not. If one of them differs on v1.0.0, we want to know, because it means that a claim of this manual depends on our machines.
What we measured, and what we read
| Subject | Measured in a run | Read in the code or the documentation only |
|---|---|---|
| Install | source build, release images, the switch to agentgateway, the kagent install, Apple silicon | GKE, the settings for large clusters |
| Lifecycle | create, resume, both suspend modes, a suspend during a request, revert, delete, crash by pod loss, node restart, the loss of one worker node of two in 17 cases, a revert and a second revert during and after that loss, a rescue from a tag, a node loss with the other node full, a drain of one worker node in five cases, memory kill, process exit with a watch of 20 minutes, an actor created before its golden snapshot, a move to another template | a drain with a PodDisruptionBudget, the wakeup probe timeout, a change of the scope of a tag |
| Snapshots | times and sizes for all three fidelities on gVisor and for memory fidelity on the microVM class, memory of zero, half random, and random bytes, a Python runtime, the two timers | restore across runtime versions |
| Capacity | a full pool, request parking, 1 to 16 resumes at the same instant, a burst of 32, bursts of 10 to 300 resumes on one node with 200 and 300 workers, the restore time inside the worker during a burst, CPU of each component during a burst, 1 to 4 replicas of ate-api-server and of the router during a burst, a single resume with 1,750 to 10,750 actor rows, a pool in another namespace, placement of 20 actors on two worker nodes, 8 and 24 parallel kagent turns | autoscaling, reservation below the limit |
| Network | default deny, the DNS relay, interception, pass-through, injection, a removed rule with an open and with a reused connection, a suspend as a cut-off, a changed Secret, a private upstream CA, a model gateway behind an https rule, the log level of the agentgateway egress gateway, every port of the router | external egress processors, an MCP gateway |
| Identity | the ingress certificate check on both dataplanes, the shared identity warning, a token file, access policies with three roles, the header and the claims of an actor JWT | the signature check of an actor JWT, key rotation, federation of actor JWTs |
| Preview features | the two switches for GoldenEgressPolicy on ate-api-server | the other two features, the flag on every component |
| Dependencies | the loss of Postgres, of ate-api-server, and of the Kubernetes API server, on Envoy and on agentgateway, and a second run of each on Envoy. Also a Postgres outside the cluster and its loss, and a bucket outside the cluster with 20 and 100 ms of added round trip and its loss, all on the same guest. Also a bucket and a Postgres on a second guest of the same server, with TLS, a rate limit, added delay, and the loss of each | a cloud bucket, a managed Postgres, a real link with 100 ms of delay |
| Upgrade | two upgrades from v1.0.0-rc.3 to v1.0.0 on one node, a second roll of the worker pool, one rollback, and an install of v0.6.0 with a direct upgrade and a reinstall | an upgrade from an older release that keeps actors, a rollback across a schema change, the supported paths |
Section 8.5 holds the full list of what we did not test.
Agent Substrate 101, edition 2026.11, by Rohit Ghumare. It is part of AI Engineering from Scratch, an open source course at aiengineeringfromscratch.com. The newest edition is always at aiengineeringfromscratch.com/manual-agent-substrate-101.html.
Agent Substrate 1.0.0 is the subject, at commit b34fbee742906494b5eb4f1f36987176bf8770a4 of 2026-11-05, checked on 2026-11-12. Every listing comes from a recorded run of the capture kit in the manual's source directory, and every rule is written in our own words from a declared source.
© 2026 Rohit Ghumare · MIT license. You can copy and share this manual. Keep this page and the copyright line with it. Report an error at github.com/rohitg00/ai-engineering-from-scratch/issues.