Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions src/content/blog/ai-agents-fall-2026-what-changed.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -651,12 +651,9 @@ If EU users talk to your support agent, the disclosure is a product requirement
"env": ".env",
"auth": "jwt",
"authorization": "ownership",
"rate_limit": {
"enabled": true,
"backend": "redis",
"requests": 100,
"window": 60,
"by": "user"
"cache_store": {
"type": "redis",
"rate_limit": {"enabled": true, "requests": 100, "window": 60, "by": "user"}
}
}
```
Expand Down Expand Up @@ -685,7 +682,7 @@ Neither deal changes anything on day one. Both are a reminder that a gateway, a
10xGraph's API server is part of the open-source install. A production project with auth, rate limits, validators, evals and tests is one command:

```bash title="Terminal"
10xgraph init --template production --auth jwt --rate-limit redis --name support-agent --yes
10xgraph init --template production --name support-agent --yes
10xgraph dev --no-open # local server on http://127.0.0.1:8000
10xgraph build --docker-compose --k8s
```
Expand Down
4 changes: 2 additions & 2 deletions src/content/blog/what-production-ready-means-in-10xgraph.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ The short version: you build the agent, and the server owns the request surface,
- A thread runs one request at a time. A second request waits up to 30 seconds, then gets 409 "thread is busy". `on_busy: "cancel"` stops the running one instead.
- A model turn runs at most 10 tool calls at once (`runs.max_parallel_tool_calls`).

**Config:** `rate_limit` (by `ip`, `user` or `global`, memory or Redis backend) is off until you add it to `10xgraph.json`. `websocket.max_connections` and `websocket.max_connections_per_user` cap sockets.
**Config:** `cache_store.rate_limit` (by `ip`, `user` or `global`) is off until you add it to `10xgraph.json`; the production template adds it. Its counters live in the API's cache store, in memory or in Redis (`cache_store.type`). `websocket.max_connections` and `websocket.max_connections_per_user` cap sockets.

**You still must:** add rate limiting, and set `trusted_proxies` to the real number of proxy hops. Streaming runs have no per-node or per-tool deadlines, only the run timeout.

Expand Down Expand Up @@ -96,7 +96,7 @@ The short version: you build the agent, and the server owns the request surface,

**Default:** `10xgraph build` writes a Dockerfile that runs Gunicorn as a non-root user, and with flags a `docker-compose.yml` and `k8s.yaml`. `GET /ping` is liveness and `GET /health/ready` is readiness. Workers hold no conversation state; it lives in the checkpointer. With Redis configured, the ownership cache, run caps and thread locks are shared across workers.

**Config:** `10xgraph build --docker-compose --k8s`, `redis` or `REDIS_URL`, `rate_limit.backend: "redis"`.
**Config:** `10xgraph build --docker-compose --k8s`, `cache_store` with `type: "redis"` (or `type: "custom"` for your own `BaseCacheStore`), `cache_store.token_revocation: true`.

**You still must:** terminate TLS at your load balancer or ingress, run two or more instances if you need availability, and size your database.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ If you run a multi-tenant product on an agent framework, the question a security

## Layer 1: identity comes from the token, nowhere else

Authentication runs before anything else. With `"auth": "jwt"`, `JwtAuth` requires `exp` on every token and a `user_id` that is a non-empty string or a whole number; anything else is a 401. When `JWT_ISSUER` or `JWT_AUDIENCE` is set, `iss` and `aud` are required and checked as well. Scope claims must have the right type, or the request gets a 401. Revoked tokens are rejected when you configure `token_revocation`, and a backend failure there fails closed with a 503.
Authentication runs before anything else. With `"auth": "jwt"`, `JwtAuth` requires `exp` on every token and a `user_id` that is a non-empty string or a whole number; anything else is a 401. When `JWT_ISSUER` or `JWT_AUDIENCE` is set, `iss` and `aud` are required and checked as well. Scope claims must have the right type, or the request gets a 401. Revoked tokens are rejected when you configure `token_revocation`, and a cache store failure there fails closed with a 503.

The verified `user_id` is the only identity the server uses. A `user`, `user_id` or `authz` key in the request config is dropped, and state or message content is never read for identity. Your tools get the verified identity through dependency injection.

Expand Down Expand Up @@ -57,7 +57,7 @@ If you write your own `AuthorizationBackend`, `isolation_scope()` defaults to `"

## Layer 6: ownership is cached, and deletes are handled

Resolving the owner on every request would cost a database call each time. Ownership never changes, so the server caches it: an in-process LRU (L1, 30 seconds by default) and, when Redis is configured, a shared tier (L2, one hour by default). When a thread is deleted, the cache entry is evicted and a tombstone is left for one L1 TTL, in L1 and in Redis. Without it, another worker that still cached the old owner could let someone else recreate the deleted id and inherit a stale record. The tombstone window is why multi-worker deployments should configure Redis: with only the in-process tier the tombstone exists on the worker that handled the delete.
Resolving the owner on every request would cost a database call each time. Ownership never changes, so the server caches it: an in-process LRU (L1, 30 seconds by default) and, when a shared cache store is configured (Redis by default), a shared tier (L2, one hour by default). When a thread is deleted, the cache entry is evicted and a tombstone is left for one L1 TTL, in L1 and in the cache store. Without it, another worker that still cached the old owner could let someone else recreate the deleted id and inherit a stale record. The tombstone window is why multi-worker deployments should configure a shared cache store: with only the in-process tier the tombstone exists on the worker that handled the delete.

## Check it yourself

Expand Down
26 changes: 15 additions & 11 deletions src/content/build/multi-tenant-support-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,24 +40,26 @@ flowchart LR
mkdir support && cd support
python -m venv .venv && source .venv/bin/activate
pip install "10xgraph[google-genai,pg_checkpoint,qdrant]" "10xgraph-api[jwt,redis]"
10xgraph init --yes --template production --auth jwt --rate-limit redis
10xgraph init --yes --template production
```

The production template writes `10xgraph.json` with JWT auth, `"authorization": "ownership"` and a Redis rate limit. Two changes matter for this guide. Point the agent at an async factory (step 2), add a store (step 3), and rate-limit by user instead of by address, so one noisy customer cannot spend another customer's quota:
The production template writes `10xgraph.json` with JWT auth, `"authorization": "ownership"`, a Redis cache store that reads `REDIS_URL`, a rate limit and token revocation. Two changes matter for this guide. Point the agent at an async factory (step 2), add a store (step 3), and rate-limit by user instead of by address, so one noisy customer cannot spend another customer's quota:

```json title="10xgraph.json"
{
"agent": "graph.agent:build_app", // [!code highlight]
"auth": "jwt",
"authorization": "ownership",
"store": "graph.memory:store", // [!code ++]
"rate_limit": {
"enabled": true,
"backend": "redis",
"requests": 100,
"window": 60,
"by": "user", // [!code highlight]
"redis": { "url": "${REDIS_URL}" }
"cache_store": {
"type": "redis",
"rate_limit": {
"enabled": true,
"requests": 100,
"window": 60,
"by": "user" // [!code highlight]
},
"token_revocation": true
}
}
```
Expand Down Expand Up @@ -186,7 +188,9 @@ store = QdrantStore(embedding=HashEmbedding(), path=":memory:")
"agent": "agent:app",
"auth": "jwt",
"store": "agent:store",
"rate_limit": { "enabled": true, "backend": "memory", "requests": 8, "window": 60, "by": "user" }
"cache_store": {
"rate_limit": {"enabled": true, "requests": 8, "window": 60, "by": "user"}
}
}
```

Expand Down Expand Up @@ -282,7 +286,7 @@ x-ratelimit-remaining: 0
"limit":8,"window_seconds":60,"retry_after_seconds":47}}
```

Alice, in the same minute, still got 200, because the bucket is per user. In your real config use the Redis backend: the memory backend counts per process, so with several workers the real limit is the number of workers times the limit. The server prints a warning at startup to say so.
Alice, in the same minute, still got 200, because the bucket is per user. In your real config set `"cache_store": {"type": "redis"}`: without it the in-memory cache store counts per process, so with several workers the real limit is the number of workers times the limit. The server prints a warning at startup to say so.

## What this guide covers, and what you still own

Expand Down
26 changes: 10 additions & 16 deletions src/content/build/production-react-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ faq:
- q: Can I use OpenAI or Claude instead of Gemini?
a: Yes. Change the model and provider arguments of ReactAgent, for example provider="openai" or provider="anthropic", and install the matching extra (openai or anthropic) instead of google-genai. The tools, graph and server stay the same.
- q: Do I need Redis running for local development?
a: REDIS_URL must be set, because graph/agent.py reads it, but the server starts without a reachable Redis. The rate limiter fails open by default, which means it logs the error and lets requests through, and without DATABASE_URL the in-memory checkpointer is used.
a: REDIS_URL must be set, because graph/agent.py reads it. The production template also turns on token_revocation, which keeps its entries in the Redis cache store, so with an unreachable Redis the server refuses to boot (set STARTUP_CHECKS=false to skip the check, or remove token_revocation while you develop). Without a reachable Redis and without that setting the server logs one warning and starts. Without DATABASE_URL the in-memory checkpointer is used.
- q: Why do the tools raise exceptions instead of returning an error message?
a: A raised exception stops the tool and marks the result as failed. The model gets a generic failure with a reference id, and the full message goes to the server log, so internal details never reach the model or the customer. A returned dictionary treats the keys error, status, is_error and success as result metadata and removes them, so an error returned that way never reaches the model either. To give the model a message it can pass on, return it as a plain string.
---
Expand All @@ -32,7 +32,7 @@ flowchart LR
A --> M[MAIN: the model]
M -->|tool call| T[TOOL: lookup_order, refund_order]
T --> M
A <--> R[(Redis: hot state, rate limits)]
A <--> R[(Redis: hot state, cache store)]
A <--> P[(Postgres: every thread)]
```

Expand All @@ -44,10 +44,10 @@ Create a folder, install the framework and the API server, and generate the proj
mkdir support-agent && cd support-agent
python -m venv .venv && source .venv/bin/activate
pip install "10xgraph[google-genai,pg_checkpoint]" "10xgraph-api[jwt,redis]"
10xgraph init --yes --template production --auth jwt --rate-limit redis
10xgraph init --yes --template production
```

The template writes a working agent with JWT auth, thread ownership checks and a Redis rate limit already set in `10xgraph.json`:
The template writes a working agent with JWT auth, thread ownership checks and a Redis cache store (`"cache_store": {"type": "redis"}`, read from `REDIS_URL`) with a rate limit and token revocation already set in `10xgraph.json`:

<FileTree>

Expand Down Expand Up @@ -195,7 +195,8 @@ load_dotenv()
# 10xgraph.json points "injectq" here. Anything bound in it is shared with the server.
container = InjectQ.get_instance()

# One Redis client for the rate limiter and the checkpointer's hot cache.
# One Redis client for the checkpointer's hot cache. The API opens its own
# connection for its cache store from cache_store in 10xgraph.json.
redis = Redis.from_url(os.environ["REDIS_URL"])
container.bind_instance("redis", redis)

Expand Down Expand Up @@ -234,26 +235,19 @@ async def build_app() -> CompiledGraph:
`ReactAgent` builds the loop for you: a `MAIN` node that calls the model and a `TOOL` node that runs the tools it asks for, until the model answers without a tool call. The rest is production plumbing:

- **`build_app` is async on purpose.** `PgCheckpointer` creates its tables only when `asetup()` is called. The server accepts an async factory as the agent entry point and awaits it at startup, so setup runs on the server's own event loop.
- **One Redis client, shared.** The rate limiter looks for a client bound as `"redis"` before creating its own, and `PgCheckpointer` takes the same client for its cache.
- **Two Redis uses, one server.** `PgCheckpointer` takes the Redis client you pass for its hot cache. The API opens its own connection for its cache store (rate limits, run locks, caps, token revocation) from `cache_store` in `10xgraph.json`, which reads `REDIS_URL` when it has no `url`. It can be a different Redis than the checkpointer's.
- **Replays skip finished tools.** With `PgCheckpointer`, a tool call that completed before a crash is recorded, and the resumed run returns the recorded result instead of calling it again. See [Replay-safe tools](/docs/concepts/replay-safe-tools).
- **Context stays bounded.** The context manager keeps the last 20 user messages in the prompt; the full history stays in the checkpointer.

Point the server at the factory and rate-limit per user instead of per IP. In `10xgraph.json`, change two values:
Point the server at the factory. In `10xgraph.json`, change one value:

```json title="10xgraph.json"
{
"agent": "graph.agent:build_app", // [!code highlight]
"rate_limit": {
"enabled": true,
"backend": "redis",
"requests": 100,
"window": 60,
"by": "user" // [!code highlight]
}
"agent": "graph.agent:build_app" // [!code highlight]
}
```

Leave the other keys as generated. Behind a load balancer every request can share one IP, so `"by": "ip"` would put all your customers in one bucket.
Leave the other keys as generated. The generated rate limit counts per user (`"by": "user"`), so behind a load balancer, where every request can share one IP, your customers do not share one bucket.

## 4. Set secrets and CORS

Expand Down
12 changes: 7 additions & 5 deletions src/content/build/ship-to-kubernetes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ flowchart LR
mkdir shop-support && cd shop-support
python -m venv .venv && source .venv/bin/activate
pip install "10xgraph[google-genai,pg_checkpoint]" "10xgraph-api[jwt,redis]"
10xgraph init --yes --template production --auth jwt --rate-limit redis --name shop-support
10xgraph init --yes --template production --name shop-support
```

That writes `10xgraph.json` (JWT auth, `ownership` authorization, a Redis rate limit at 100 requests per 60 seconds per IP, `${REDIS_URL}` read from the environment), a `graph/` package with a sample weather agent, tests and evals. Build the Docker image only after two edits.
That writes `10xgraph.json` (JWT auth, `ownership` authorization, a Redis cache store that reads `REDIS_URL`, token revocation on, and a rate limit at 100 requests per 60 seconds per user, with the counters shared through that Redis), a `graph/` package with a sample weather agent, tests and evals. Build the Docker image only after two edits.

First, the generated Dockerfile installs exactly the `dependencies` in `pyproject.toml`, so list the Postgres extra there:

Expand Down Expand Up @@ -135,7 +135,7 @@ A Deployment and a Service named after `--service-name`. The values that matter:
| `replicas` | 2 | Redundancy. The manifest has no autoscaler. |
| `terminationGracePeriodSeconds` | 660 | Longer than the 600-second graceful timeout, so Kubernetes does not send SIGKILL while runs drain. |
| `preStop` | `sleep 15` | Gives the load balancer time to stop sending new requests before the app starts shutting down. |
| `readinessProbe` | `GET /health/ready`, every 10 s, first check after 5 s | Takes a pod out of rotation when its checkpointer, Redis or store cannot be reached. |
| `readinessProbe` | `GET /health/ready`, every 10 s, first check after 5 s | Takes a pod out of rotation when its checkpointer or store cannot be reached, or when the cache store cannot be reached and token revocation or a fail-closed rate limit needs it. |
| `livenessProbe` | `GET /ping`, every 30 s, first check after 30 s, 5 failures | Deliberately slack. A worker busy with a long run must not be mistaken for a hung one. |
| Security | non-root uid 10001, `readOnlyRootFilesystem`, `allowPrivilegeEscalation: false`, `capabilities.drop: ALL`, `seccompProfile: RuntimeDefault`, `automountServiceAccountToken: false` | The server never calls the Kubernetes API and writes only to `/tmp` and `/app/uploads`, both `emptyDir`. |
| Secrets | `envFrom: secretRef: shop-support-secrets` | Every key of that Secret becomes an environment variable. The pod does not start until it exists. |
Expand Down Expand Up @@ -176,6 +176,8 @@ healthy

The image builds, runs as uid 10001 on a read-only root, and starts with all capabilities dropped. `/ping` stays 200 while `/health/ready` is 503 because Redis cannot be reached. That split is what the manifest relies on. Readiness fails, so the pod is taken out of the Service and receives no new requests. Liveness still passes, so Kubernetes does not restart a pod that is merely waiting for a dependency. The unauthenticated request got 401, because every route except `/ping` and `/health/ready` needs a token. Stop and remove the container with `docker rm -f shop`.

This transcript comes from a 0.9.0 image. With the current server it also refuses to boot when the cache store is unreachable and token revocation is on (the production template turns it on), the readiness key is `cache` instead of `redis`, and `STARTUP_CHECKS=false` skips the boot check if you want to see the probes alone.

The full Compose file with Postgres and Redis passed `docker compose config`. I did not start it, and I did not run the app against a real Postgres.

## 4. Secrets and dependencies
Expand Down Expand Up @@ -212,9 +214,9 @@ kubectl scale deployment shop-support --replicas=5

Three things change when you go past one process:

- **Use the Redis rate limiter.** The memory backend counts per process, so the real limit is the limit times the number of workers, and the server prints a warning at startup to say so.
- **Use a shared cache store.** Set `"cache_store": {"type": "redis"}` (with `REDIS_URL` set, or a `url`). The in-memory store counts per process, so the real rate limit is the limit times the number of workers, and token revocations reach only one process. The server warns about it at startup.
- **Set load balancer idle timeouts above your longest streaming run.** The pod's grace period is 660 seconds, but a proxy that cuts idle connections at 60 seconds will still break a stream.
- **One run per thread.** A second request to a thread that is busy waits up to 30 seconds and then gets 409. With Redis configured, that lock is shared by all replicas.
- **One run per thread.** A second request to a thread that is busy waits up to 30 seconds and then gets 409. With a Redis cache store configured, that lock is shared by all replicas.

For autoscaling, CPU is a poor signal for agents, which wait on a model provider. Scale on concurrent requests per pod where your ingress can export it. [Scaling and reliability](/docs/production/scaling) has the details.

Expand Down
2 changes: 1 addition & 1 deletion src/content/docs/client/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ const result = await retryWithBackoff(() =>

The server returns 429 when the `rate_limit` window is used up, or when one user has more graph runs in flight than `runs.max_concurrent_per_user` allows (default 10). The rate-limit response includes a `Retry-After` header in seconds and ends its message with `Retry after <n>s.` The run-cap response has no `Retry-After` header. The client has no class for 429, so it throws a plain `TenxGraphError` with `statusCode` 429, and it does not keep the response headers.

If the server's rate-limit backend (Redis) is down and `fail_open` is `false`, it answers `503` with `RATE_LIMIT_UNAVAILABLE` and `Retry-After: 5` instead. Retry on 503 the same way you retry on 429. Added after 1.0.0rc2.
If the server's cache store (Redis) is down and `fail_open` is `false`, it answers `503` with `RATE_LIMIT_UNAVAILABLE` and `Retry-After: 5` instead. Retry on 503 the same way you retry on 429. Added in 1.0.0rc3.

```ts
import { TenxGraphError } from '10xgraph-client';
Expand Down
Loading
Loading