Space Pools
Space pools keep a fixed number of identical spaces running from a template. A
pool stores a target desired_count and an active flag. The cluster leader
reconciles pools every 15 seconds: it replaces dead members, creates new members
when the pool is below target, drains method traffic before stopping excess
members, and applies a grace period before deleting stopped spaces.
Pools do not include built-in autoscaling. Knot exposes utilization stats and a runtime size API so you can write your own scaler in Scriptling or another external tool. Pools can also be configured for exclusive member leases — checking one member out for a caller’s private use for a bounded time (see Member Leases).
Port Routing
Pool member spaces expose their HTTP and TCP ports under the pool name
rather than the individual space name. For example, if user alice has a pool
named search-api with an HTTP port on 8080:
https://alice--search-api--8080.knot.example.comThe proxy resolves the pool name, picks a healthy member via round-robin, and routes the request to it. Drained members (being removed) are skipped automatically. Exclusively leased members are skipped too — while held, a member serves only its lease holder. If no healthy member is available, the proxy returns 404.
TCP ports work the same way via the WebSocket proxy endpoint
/proxy/spaces/{pool_name}/port/{port}.
Lifecycle
When desired_count is reduced on a running pool, excess spaces go through a
multi-sweep transition:
- Drain — new JSON-RPC method calls stop routing to the space (15s buffer for in-flight requests to complete).
- Stop — the container is stopped.
- Grace period — the stopped space survives one extra sweep cycle, allowing
it to be restarted if
desired_countgoes back up. - Delete — the space is permanently removed.
If the pool’s desired_count increases before step 3 completes, the space is
undrained and continues running without interruption.
Stopping a pool sets active = false and stops all member spaces without
deleting them. Starting a stopped pool starts all members and creates new ones
if needed.
Deleting a pool requires it to be stopped first. Member space deletion is initiated (marked as deleting), then the pool definition is tombstoned. The container service completes volume cleanup and finalises space deletion asynchronously.
Member Leases
By default every request into a pool is load-balanced across the healthy members — nothing is reserved. A lease-enabled pool adds a checkout flow on top: a caller acquires one warm, healthy member for exclusive use, works with it, and releases it back to the pool. Acquire is instant — it only ever picks members that are already running and healthy, so there is never a wait for a space to be ready. Typical uses are scripts, agents, and CI jobs that each need their own instance of a browser, build environment, or service without colliding.
The simplest configuration is no limit: leases run until released, with
no expiry to think about. This is the natural fit for personal development —
the only failure mode is a script that crashes between acquire and release,
which leaves the member leased until you release it by hand
(knot pool leases shows what’s held).
Leases are configured per pool at creation (or later via the update API):
lease_max_time—-1(no limit) makes leases run until released; a positive value sets an optional safety net — a lease that hits its deadline returns its member to the pool automatically, which self-heals a forgotten release.0(the default) disables leases entirely.lease_max_extensions— how many times a lease may be extended, for time-limited pools:0forbids extending,-1allows unlimited.
Acquire takes an optional duration: omitted, it uses the pool’s maximum
(-1 pools grant never-expiring leases); explicitly, it must not exceed the
pool maximum, and -1 (never expire) is only valid on no-timeout pools.
Lease settings are read live at each operation — changing them takes effect immediately for new acquires, with nothing to roll out to members. Held leases are not rewritten: their deadlines were fixed when they were granted, though the extension cap applies to the next renewal attempt. Turning leases off lets held leases end naturally (expiry or release) but refuses renewals.
What exclusivity means
While a lease is held:
- Shared method routing (JSON-RPC and MCP) skips the member. If every
remaining provider of a method is leased, callers get a
409response — “method exclusively leased” — rather than a load-balanced call. - Pool-name port routing (
user--poolname--port) skips the member. - The holder reaches their member two ways: by its own member name
(
user--poolname-3--port— unchanged, direct), or by pinning method calls to it with thespace_idfield on/api/methods/callrequests.
Leases are granted per member, not per user account — any token of the pool’s owner sees the same pool, and the lease serialises concurrent consumers.
Expiry and in-flight work
When a lease reaches its deadline (or is released early) the member does not
re-enter rotation immediately: it stays excluded until in-flight method
calls have finished — each bounded by its per-method timeout — and then the
sweep returns it to the pool, normally within one 15-second cycle. A lease in
this state shows as draining. Open HTTP/TCP connections do not hold a
member. A lease can be extended while draining (until reclaimed), which
rescues a lease that ran out by accident.
The pool reconciler never shrinks, stops, or auto-stops (template max uptime) a leased member; stopping a pool is rejected while leases are active.
Acquire, extend, release
# The simple flow: allocate, use, release (on a no-timeout pool)
knot pool acquire build-workers
knot pool release build-workers-0 # the member's name — no pool needed
# Done with it and want a clean slate next time? Destroy the member —
# a fresh replacement is created, and the next acquire gets a clean space
knot pool release build-workers-0 --destroy
# See who holds what
knot pool leases build-workers
# Structured output for scripts
knot pool acquire build-workers --json | jq -r .space_name
# Time-boxed variant (pool with a limit): check out for 5 minutes,
# optionally waiting up to 2m for a free member, and renew if needed
knot pool acquire build-workers --time 5m --wait 2m
knot pool extend build-workers-0 --time 5macquire waits up to 10 seconds by default — long enough to pick up a
member that is mid-start or just being replaced — and --wait 0s fails
immediately. release --destroy (or destroy=true on the API’s
DELETE /api/spaces/{space_id_or_name}/lease) deletes the member through the
normal drain-and-delete flow and starts a fresh replacement right away, so
the pool stays at its desired count.
On a no-timeout pool a plain acquire (no --time) grants a
never-expiring lease; --time none says so explicitly.
In Scriptling:
import knot.pool as pool
with pool.leased("browsers") as member:
# member["space_id"] / member["space_name"] identify the held instance;
# pin method calls to it while held
...
# released automatically on exit — even on exceptionSee knot.pool for the full lease API.
What Pools Track
Pool utilization is calculated from the latest agent state reports and the method registry:
- Combined request rate across JSON-RPC methods, HTTP requests, and TCP connections
- JSON-RPC in-flight method calls
- Average CPU and memory usage across live members
- Per-member state and utilization for debugging
Scriptling
Use knot.pool to inspect pools and update their target size:
import knot.pool as pool
info = pool.get("search-pool")
util = info["utilization"]
if util["combined_rps"] > 100:
pool.set_size("search-pool", info["desired_count"] + 1)set_size() updates the target count immediately. The sweep loop handles
draining, stopping, and deleting excess spaces within 1-2 cycles.
Creating a Pool
Pools are created through the API (or a stack definition). A pool needs a name and a template; it starts stopped unless active is set:
curl -X POST -H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "build-workers",
"template_id": "<template-uuid>",
"desired_count": 3,
"active": true
}' \
https://knot.internal:3000/api/poolsname: Pool name, also used as the routing endpoint.template_id: The template pool members are created from (required).startup_script_id: Optional startup script for members.desired_count: Runtime target member count (default 1).active: Start the pool immediately whentrue(defaultfalse).lease_max_time: Enable exclusive member leases —-1for allocate/use/release with no timeout (the web form’s default when leases are enabled), a positive number of seconds for an optional expiry safety net,0(default) to disable leases.lease_max_extensions: Max extensions per lease —-1for unlimited (the default pairing with no-limit),0to forbid extending.
API
The pool API is available to authenticated callers:
GET /api/poolsPOST /api/poolsGET /api/pools/{id_or_name}PUT /api/pools/{id_or_name}DELETE /api/pools/{id_or_name}POST /api/pools/{id_or_name}/sizePOST /api/pools/{id_or_name}/startPOST /api/pools/{id_or_name}/stopPOST /api/pools/{id_or_name}/acquire— grant an exclusive member lease (optionally long-poll withwait_seconds, max 300)GET /api/pools/{id_or_name}/leases— list held leasesPOST /api/spaces/{space_id_or_name}/lease/extend— renew a leaseDELETE /api/spaces/{space_id_or_name}/lease— release a lease early (?destroy=truedestroys the member and starts a fresh replacement)
Pool operations require Use Space Pools permission.
CLI
knot pool list # List your pools
knot pool start <name> # Start a stopped pool
knot pool stop <name> # Stop a running pool
knot pool set-size <name> <count> # Change the desired space count
knot pool delete <name> [-y] # Delete a stopped pool (prompts unless -y)
knot pool acquire <name> [--time 5m|none] [--wait 2m] # Check a member out exclusively
knot pool extend <member> [--time 5m|none] # Renew the lease on a held member
knot pool release <member> [--destroy] # Return a member early (or destroy it for a clean replacement)
knot pool leases <name> # List held leases