My coding agent doesn't get tired, and it doesn't get embarrassed. If a request fails, it sends it again. If its app crashes on startup, it redeploys and watches it crash again. A person would stop after the third try and go read the logs. An agent with a loop and an API key can keep going all night.
So when I built a server for agents to deploy to, I assumed the user would be tireless and sometimes wrong. I put the limits in the server itself, because I can't count on every agent to behave.
This post walks through four of those limits in agentic-hosting, an open-source tool that turns a Linux server into a place where AI agents can deploy apps and databases on their own. For each one, I'll show what your agent sees when it kicks in and the requests that get things moving again.
⭐ If this is useful, a star on GitHub helps other people find it.
🤖 Give this to your agent: point it at https://raw.githubusercontent.com/dennisonbertram/agentic-hosting/main/llms.txt and it can sign up and set things up from that one link.
Why an agent needs different limits
Most hosting tools assume a person is on the other end. A person clicks deploy once, sees the error, and goes to fix it. An agent has different habits. It retries anything that looks like it failed, and it can fire off a lot of requests in a few seconds. Those are good habits most of the time. They go wrong in four places: requests that get done twice, apps stuck in a crash loop, a disk that fills up, and too many requests at once.
agentic-hosting is a single program called ah that runs on your server. Agents talk to it over an HTTP API. In the examples below, $AH_URL is your server's address and $AH_KEY is the agent's API key.
Retries that don't do the work twice
Say the agent asks for a Postgres database. The server creates it, but the response never makes it back. Maybe the connection dropped, maybe a proxy in between gave up. From the agent's side the request failed, so it asks again, and now there are two databases. Database names don't have to be unique, and each agent can have three databases by default, so a couple of lost responses can use up its whole quota.
The fix is an idempotency key. It's a unique string the agent makes up and sends in a header (uuidgen below just prints a random ID). The server remembers each key it has seen along with the answer it gave. If the same key shows up again, the server sends back the saved answer instead of doing the work a second time.
IDEM=$(uuidgen)
BODY='{"name":"appdb","type":"postgres"}'
curl -s -X POST "$AH_URL/v1/databases" \
-H "Authorization: Bearer $AH_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM" \
-d "$BODY"
Send the same request again within ten minutes, with the same key and body, and you get the first response back, with the same database ID, plus a header that says it was a replay:
curl -si -X POST "$AH_URL/v1/databases" \
-H "Authorization: Bearer $AH_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM" \
-d "$BODY" | grep -i '^idempotency-replayed'
# Idempotency-Replayed: true
A few rules are worth knowing, because they decide whether a retry is safe:
-
Keys last 10 minutes. After that the server forgets them. It also keeps them only in memory, so restarting
ahclears them. -
The key, path, and body have to match. A key only counts for the same agent and the same URL. The server also stores a fingerprint of the request body with the key. Same key with a different body gets
409and{"error":"idempotency key reused with different request body"}. Build the body once and reuse it, as above, instead of rebuilding the JSON on each try. - Only successes are saved. If the first request returned an error, a retry with the same key runs again. That way one temporary failure doesn't get stuck as the permanent answer.
- A retry that arrives early waits. If the first request is still running, the second one waits for it and gets the same answer.
- It covers POST requests. That's every create and action route. Deletes and edits aren't covered, and they don't need to be, because doing them twice leaves things the same as doing them once. Creating an API key is left out on purpose, since the secret should be shown only once.
Keys can be up to 128 characters. A UUID per intended action is the simplest choice. Make a new key when you mean to do something new, and reuse it only when you're retrying.
Crash loops that stop on their own
When an app crashes, Docker (the tool that runs each app in its own container) starts it again. That's the right call for a crash every few days. It's the wrong call for an app that crashes the moment it starts, because Docker will keep restarting it all night, and the agent watching it may keep redeploying it too.
ah checks running services every 30 seconds and counts restarts. A failed health check counts as a crash too. If a service crashes 5 times within 10 minutes, ah trips a circuit breaker. The name comes from the switch in your fuse box that cuts power when something's wrong. Here, ah stops the container, removes it, and refuses to start it again until someone deals with it.
Your agent sees it on the service itself (this uses jq to pick out the fields):
curl -s -H "Authorization: Bearer $AH_KEY" \
"$AH_URL/v1/services/$SERVICE_ID" \
| jq '{status, crash_count, circuit_open, last_error}'
# {
# "status": "crashed",
# "crash_count": 5,
# "circuit_open": true,
# "last_error": "container restarted 1 time(s) by ..."
# }
While the breaker is open, start, restart, and redeploy all return 409 with this message:
circuit breaker is open: service has crashed too many times; use POST /reset to clear
There's a catch. Because the container is gone, the logs are gone with it, and GET /v1/services/{id}/logs returns 409 with "service has no container". So the best time to read logs is before the breaker trips, while the status says crashed. After it trips, the clues left are last_error and the deployment history, which records every crash with its exit code:
curl -s -H "Authorization: Bearer $AH_KEY" \
"$AH_URL/v1/services/$SERVICE_ID/deployments" \
| jq -r '.[] | "\(.status) \(.error_message)"'
Once the agent has a fix, it resets the breaker and deploys again:
curl -s -X POST -H "Authorization: Bearer $AH_KEY" \
"$AH_URL/v1/services/$SERVICE_ID/reset"
# {"status":"circuit breaker reset"}
curl -s -X POST -H "Authorization: Bearer $AH_KEY" \
"$AH_URL/v1/services/$SERVICE_ID/redeploy"
Reset leaves the service stopped with no container, so use redeploy, not start. Redeploy runs the same image (the packaged app that Docker runs) with the current environment variables, which is right when the fix was a missing variable. If the fix was in the code, reset first and then start a new build (turning the updated source code into a new image) with POST /v1/services/{id}/builds, because a build can't deploy while the breaker is open.
If nobody resets it, ah closes the breaker by itself after 30 minutes and leaves the service stopped. Nothing restarts the app automatically. If the same service trips again, the wait becomes 1 hour, and then 4 hours for every trip after that. That count doesn't go back down after a manual reset, so a service that keeps breaking gets treated with more suspicion over time.
A disk that says no before it's full
Agents build a lot. Every build leaves an image behind, databases need space, and a full disk breaks everything on the server at once, including the tools you'd use to clean it up.
So before every deploy, build, and new database, ah checks how full two folders are: /var/lib/ah, where it keeps its own data, and /var/lib/docker, where images and containers live. These thresholds are often called watermarks. At 80% used, ah writes a warning to its log and carries on. At 90%, it refuses the work.
What your agent sees depends on the request. Creating a service is accepted right away and deployed in the background, so the deploy fails later and the reason shows up on the service:
curl -s -H "Authorization: Bearer $AH_KEY" \
"$AH_URL/v1/services/$SERVICE_ID" | jq -r .last_error
# /var/lib/docker: insufficient disk space at
# /var/lib/docker (91.4% used, threshold 90%)
Builds, new databases, restarts, and redeploys check the disk while the request is still open. Right now those come back as a plain 500 with {"error":"internal error"}, and the real reason is only in the server log. That's a rough edge, and I'd like those to say "disk full" instead.
Until then, the health endpoint gives the agent a direct answer. It reports both folders, and its overall status turns to degraded when either one is over 90%:
curl -s -H "Authorization: Bearer $AH_KEY" \
"$AH_URL/v1/system/health/detailed?fresh=true" \
| jq '{status, disk, docker_disk}'
# {
# "status": "degraded",
# "disk": {"total_gb": 75, "free_gb": 41.2, ...},
# "docker_disk": {..., "used_percent": 91.4}
# }
The 80% warning only reaches the server log, so an agent that wants an early heads-up should check used_percent itself before a big batch of builds and clean up old services first. The health result is cached for 30 seconds, and ?fresh=true cuts that to 10.
A speed limit for each agent
Each agent gets its own account on the server, which ah calls a tenant, with its own API key. Every tenant has a rate limit of 100 requests per second, with a burst of 200. That means it can send 200 requests in a quick burst, and after that it gets about 100 more each second. The whole server also has a shared limit of 500 per second, with a burst of 1,000, so many agents together can't swamp it either.
Every authenticated response tells the agent where it stands:
curl -si -H "Authorization: Bearer $AH_KEY" \
"$AH_URL/v1/services" | grep -i '^x-ratelimit'
# X-Ratelimit-Limit: 100
# X-Ratelimit-Remaining: 187
Go over and the server answers 429 with {"error":"rate limit exceeded"} and a Retry-After header saying how many seconds to wait, between 1 and 60.
There's a second limit for slow requests, like following logs or creating a database. Each tenant can have 4 of those open at once. A fifth gets 429 with Retry-After: 5.
It helps to teach your agent the difference between 429 and 409. A 429 means "slow down and try the same thing again." A 409 means the request itself ran into something, like a full quota ("quota exceeded: maximum 3 databases reached") or an open circuit breaker. Waiting won't fix a 409, so the agent should read the error before it tries again.
What I tell my agent
If you write your own instructions for an agent that uses agentic-hosting, this is the short version I'd paste in:
- Send an Idempotency-Key on any POST you might retry.
On retry, reuse the same key and the exact same body.
- On 429, wait Retry-After seconds, then retry.
- On 409, read the error before trying again.
- If circuit_open is true: read last_error and
/deployments, fix the cause, POST /reset once,
then POST /redeploy (or start a new build).
- Before a big batch of builds, check
/v1/system/health/detailed for disk used_percent.
These limits don't make the agent any smarter. They keep one bad loop from taking down the server, and they leave a clear error behind for the agent to read.
Wrapping up
An agent that never stops is a great user until it's wrong. agentic-hosting answers with four limits: idempotency keys so retries don't double up, a circuit breaker for crash loops, a disk check that refuses new work at 90%, and a rate limit for each agent. Each one tells the agent what happened, so it can fix the cause instead of trying harder.
- Star agentic-hosting on GitHub so more people find it.
- Give it to your agent: llms.txt to sign up and set up, or the agent skill for daily use.
- Try the quickstart, or start at the docs site.
- Open an issue if your agent finds a way to get stuck that these limits don't catch. I want to hear about it.
Top comments (0)