Your agent works. Give it a URL.
Layernetes is the Learning Layer cloud for CrewAI-powered agents. Write the agent locally, run llnate push, and it goes live at a public URL any human or any AI can call over MCP or plain HTTP.
It's built by Sanscourier.ai for the Learning Layer cloud. The full platform is open source; hosted access is members-only for now. See License.
If you're using Claude Code, this is the preferred way to get started:
claude -p "fetch agents.wtp.io/setup and set llnate"That prompt fetches the agent-readable setup instructions at agents.wtp.io/setup and walks Claude through the full llnate flow for you: install the CLI, scaffold the project, wire in the coding hooks, log in, encrypt keys, and ship.
If you want the same instructions directly, read layernetes.wtp.io/setup.
New here? Start with Introducing Layernetes for the guided tour. What follows is the operator's version.
llnate init my-agent # scaffold your agent plus everything the cloud expects
cd my-agent
llnate plugin install # bring your AI coding assistant into the loop
# ... build your agent, the way you build everything now ...
llnate login # sign in and provision your cloud repo and age key
llnate keys # encrypt your credentials into the repo
llnate push # deploy, wait until live, print the public URLsllnate push blocks until your agent is live, then prints its public URLs. Every deployed agent speaks both:
- MCP — every LLAgent ships with a built-in FastMCP server, so it plugs directly into MCP clients like Claude.
- HTTP API — a plain FastAPI interface for everything else.
Your agent needs secrets (model API keys, service credentials) but they should never land in a repo in plaintext. Layernetes uses sops with age encryption:
- When you
llnate login, you receive an age public key, e.g.age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p. llnate keysencrypts your credentials with that key into akeys.envfile that is committed alongside your code.- The matching private key is stored as a Kubernetes Secret in the Learning Layer cloud, guarded by RBAC and encrypted at rest (Talos-native etcd encryption).
- At deploy time,
ll-operatormounts the age key and your still-encryptedkeys.envinto your agent's pod. The agent entrypoint (scaffolded byllnate init) decrypts them in-memory at startup viasops exec-env— plaintext credentials never touch etcd, a rendered Secret, or disk.
Your credentials are readable by exactly one thing: your running agent — literally. (Vault is the planned upgrade path once we need audit logging and key rotation.)
- Login.
llnate loginprompts for your admin-provisioned username and password in the terminal and sends them toll-api, our control-plane service, which mints a Gitea personal access token — that token is the bearer for every user endpoint. (A real OAuth flow is the planned upgrade; the endpoint shapes are already OAuth-compatible — see the module docstring inll-api/app/main.py.)ll-apiprovisions your repo on the Gitea instance in the Learning Layer cloud, generates your age keypair (storing the private half as an RBAC-guarded Kubernetes Secret), and configures your local git remote. - Push.
llnate pushpushes your code to Gitea. A Gitea Actions pipeline (scaffolded byllnate init) builds your container image and pushes it to Gitea's built-in OCI registry. - Deploy. The pipeline reports the new image to
ll-api, which updates yourLLAgentcustom resource.ll-operatorreconciles it: creates your agent's namespace and deploys the container, mounting your age key and encryptedkeys.env. The agent entrypoint decrypts them in-memory at startup (sops exec-env), so plaintext secrets never exist outside the running process. - Expose.
ll-operatorcreates an Ingress for the agent with its<sha>hostname — the URL includes the SHA of the deployed code, so every revision has a stable, addressable URL. A shared Cloudflare Tunnel (cloudflaredrunning in the cluster) forwards the wildcard*.agents.hostname to the in-cluster ingress controller, which routes by hostname from there. - Report back.
llnate pushstreams build and deploy progress fromll-apiand exits by printing your agent's MCP and HTTP URLs. - Serve. Clients reach the agent through that URL via MCP (FastMCP) or HTTP (FastAPI).
Every LLAgent runs as a container in its own Kubernetes namespace, giving two layers of isolation: namespaces (with restricted permissions and network policies) separate agents from each other, and the cluster nodes themselves run on Cloud Hypervisor VMs, separating agent workloads from the host.
developer Learning Layer cloud (K8s on Talos Linux)
───────── ──────────────────────────────────────────
llnate ──login──▶ ll-api ─────▶ provisions Gitea repo + age keys
llnate ──push───▶ Gitea ──▶ Gitea Actions ──build──▶ OCI registry
│
ll-api ──▶ LLAgent CR ──▶ ll-operator
│
age key (K8s Secret) ──mounted──▶ LLAgent pod
(own namespace,
in-memory decrypt,
FastMCP + FastAPI)
│
users ◀── https://<sha>.… ◀── Cloudflare Tunnel ◀── Ingress ◀┘
These four interfaces are the seams between packages. They are frozen for the MVP — change them only with a PR that updates this section.
The contract between ll-api (writes it) and ll-operator (reconciles it):
apiVersion: layernetes.learninglayer.ai/v1alpha1
kind: LLAgent
metadata:
name: gonz-hello-agent # <owner>-<agent>, unique cluster-wide
namespace: layernetes # CRs live in the platform namespace
spec:
owner: gonz # Gitea username
repo: gonz/hello-agent # Gitea repo
image: gitea.../gonz/hello-agent:3f2a91c
sha: 3f2a91c # short SHA; becomes the hostname
keySecretRef: age-key-gonz # Secret holding the age private key
status:
phase: Pending | Deploying | Ready | Failed
url: https://3f2a91c.agents.layernetes.learninglayer.ai
message: "" # human-readable detail on FailedName derivation: Kubernetes object names (metadata.name, keySecretRef, the agent namespace agent-<owner>-<agent>) must be RFC 1123 labels, but Gitea usernames may contain characters K8s forbids — most commonly _ (e.g. learninglayer_glo). ll-api runs the username through kube.k8s_name when deriving any resource name, so learninglayer_glo yields age-key-learninglayer-glo and a CR named learninglayer-glo-<agent>. The raw username is preserved in spec.owner (used for authorization) and in the repo/image paths (Gitea and OCI both permit _). Because clients (llnate, CI) reconstruct the raw <owner>-<agent> name, the ll-api endpoints that take a {name} sanitize it on the way in, so both the raw and sanitized forms resolve.
Revision semantics (MVP): only the latest revision runs. A new spec.sha replaces the agent's Deployment and Ingress; previous <sha> hostnames stop resolving. Revision history and rollback are post-MVP.
Everything llnate and CI talk to. Auth: Authorization: Bearer <token> — user tokens issued at login, repo-scoped tokens injected into CI at provisioning time.
| Endpoint | Auth | Purpose |
|---|---|---|
POST /v1/auth/login |
none | Password login (MVP): {"username", "password"} → mints a Gitea personal access token and returns it as the user token. Endpoint shape is OAuth-compatible for the planned upgrade |
GET /v1/me |
user | Identity + the user's age public key (used by llnate keys) |
POST /v1/agents |
user | Provision: create Gitea repo, generate age keypair, create LLAgent shell |
POST /v1/agents/{name}/builds |
CI | CI callback: {"sha": "...", "image": "..."} — updates LLAgent.spec |
GET /v1/agents/{name}/status |
user | {"phase": "...", "url": "...", "message": "...", "sha": "..."} — llnate push polls this every 2s. sha is the deployed revision (from LLAgent.spec.sha); push waits until it matches the sha it pushed, so a stale status from the previous revision can't end the poll early. |
DELETE /v1/agents/{name} |
user | Teardown: delete LLAgent, namespace, Gitea repo (backs llnate delete) |
What every LLAgent container must expose — the llnate init template and llagent-base image guarantee it, and ll-operator depends on it:
- Port
8000, plain HTTP. GET /healthz— liveness/readiness; the operator gatesReadyon it./mcp— the FastMCP server./docs+ REST routes — the FastAPI surface.- User code convention: the project exposes a CrewAI
crewobject increw.py; the base image's entrypoint imports it and mounts it behind FastMCP and FastAPI. - Startup: the entrypoint runs
sops exec-env keys.env(age key mounted at/var/run/secrets/llnate/age.key) so decrypted credentials exist only in process memory.
The Gitea Actions workflow (scaffolded by llnate init) builds the image, pushes it to the Gitea OCI registry, then reports:
POST /v1/agents/{name}/builds
Authorization: Bearer <repo-scoped token, injected as an Actions secret at provisioning>
{"sha": "3f2a91c", "image": "gitea.../gonz/hello-agent:3f2a91c"}
That call is the only coupling between CI and the platform — the pipeline knows nothing about Kubernetes.
| Package | Stack | Description |
|---|---|---|
llnate |
Python + Typer | CLI for the developer loop: init, plugin install, login, keys, push |
ll-api |
Python + FastAPI | Control-plane API: OAuth login, Gitea repo provisioning, age keypair issuance, deploy status |
ll-operator |
Python + kopf | Kubernetes operator that reconciles LLAgent resources: namespaces, secrets, deployments, ingresses |
llagent-base |
Docker | Base image for all agents: Python, pinned CrewAI, the FastMCP/FastAPI wrapper, sops/age, entrypoint |
ll-infra |
Helm | Charts for the platform: Gitea (git + Actions + OCI registry), ll-api, ll-operator, cloudflared |
Python everywhere, on purpose: one language across CLI, control plane, and operator means shared models (the CLI and ll-api use the same pydantic schemas) and no context switching. Routing needs no custom edge code — the ingress controller routes <sha> hostnames, and the Cloudflare Tunnel just points at it.
The production cluster runs on Talos Linux, a minimal, API-managed OS built for Kubernetes. To stand the platform up on a fresh Talos cluster — one script, with the ArgoCD/GitOps and reachability details — follow docs/TALOS.md.
The entire platform — Gitea, ll-api, ll-operator, and deployed LLAgents — runs on any Kubernetes cluster, including a laptop or a Mac mini. This is the primary development environment (the Talos cluster is not always... plugged in). macOS with kiac is the preferred setup; Colima, k3s, and kind also work.
Everything — the git-push-to-deploy pipeline, sops/age secrets, the operator — runs exactly as in production. Since ll-operator always exposes agents via Ingress, the only real difference is what sits in front of the ingress controller: a Cloudflare Tunnel in production, nothing locally.
| Concern | Production | Local |
|---|---|---|
| Cluster | Talos Linux on Cloud Hypervisor VMs | kiac / Colima / k3s / kind |
| Reaching agents | Cloudflare Tunnel → in-cluster Ingress | same Ingress, reached directly (sslip.io hostnames) |
| Node isolation | Cloud Hypervisor VMs | kiac: per-node VMs; others: namespaces only |
| Git + CI + registry | Gitea (in-cluster) | same |
| Secrets | K8s Secret + sops exec-env |
same |
brew install kubectl helm sops age git # Linux: use your package managerPlus a cluster — pick one:
macOS, Apple silicon — kiac (preferred). "Kubernetes in Apple Containers" boots each node in its own lightweight VM via Apple's Virtualization framework — the closest local analogue to our production Cloud-Hypervisor nodes. Requires Apple's container 1.0 runtime (macOS 26+ for multi-node).
# after installing apple/container 1.0 from Apple's releases:
brew install saiyam1814/tap/kiac
kiac doctor
kiac create cluster --workers 2
kubectl get nodes # each node is its own VMkiac ships MetalLB, local-path storage, and metrics-server, but no ingress controller — install ingress-nginx (command below). Three kiac-specific setup steps, all scripted in hack/:
# MetalLB ships with no address pool — give it one on the VM subnet, then
kubectl apply -f - <<'EOF' # (or use your own range)
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata: {name: kiac-pool, namespace: metallb-system}
spec: {addresses: [192.168.64.200-192.168.64.220]}
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata: {name: kiac-l2, namespace: metallb-system}
spec: {ipAddressPools: [kiac-pool]}
EOF
hack/kiac-net-offload-fix.sh # vmnet mangles TSO/GSO on forwarded pod traffic; rerun after node restarts
hack/kiac-registry-trust.sh gitea.<LB-IP>.sslip.io 10.96.100.100 # plain-HTTP registry trust + direct routeInstall the platform with the kiac overlay layered on top of the local values (-f ll-infra/values-local.yaml -f ll-infra/values-kiac.yaml) — it moves hostnames to *.<LB-IP>.sslip.io and routes in-cluster registry traffic around the ingress. hack/kiac-load.sh <image> is the kind load equivalent for iterating on ll-api/ll-operator.
macOS — Colima.
brew install colima docker
colima start --kubernetes --cpu 4 --memory 8 --disk 60
kubectl config use-context colimaLinux — k3s (or kind).
curl -sfL https://get.k3s.io | sh - # or: kind create cluster --name layernetesFor kiac and kind, install an ingress controller (Colima/k3s ship Traefik):
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yamlll-infra ships a values-local.yaml that enables Gitea (with Actions and the OCI registry), deploys ll-api and ll-operator, disables cloudflared, and sets hostnames under *.127.0.0.1.sslip.io (resolves to localhost, no /etc/hosts edits).
helm dependency build ll-infra
helm install layernetes ./ll-infra -n layernetes --create-namespace \
-f ll-infra/values-local.yaml
kubectl -n layernetes get pods -w # wait for gitea, ll-api, ll-operator, runnerGet traffic to the ingress controller:
- kiac: no port-forward needed — MetalLB gives the ingress controller a real external IP (
kubectl -n ingress-nginx get svc); set the chart hostnames to*.<that-IP>.sslip.ioand use port 80 directly. - Everything else:
kubectl -n kube-system port-forward svc/traefik 8080:80(kind: forwardsvc/ingress-nginx-controlleriningress-nginxinstead), then usehttp://gitea.127.0.0.1.sslip.io:8080,http://api.127.0.0.1.sslip.io:8080,http://<sha>.agents.127.0.0.1.sslip.io:8080.
Point the CLI at your local control plane, then run the same flow a real user runs — this is the end-to-end smoke test and our acceptance test:
export LLNATE_API_URL=http://api.127.0.0.1.sslip.io:8080
llnate init hello-agent && cd hello-agent
llnate login # OAuth against your local ll-api / Gitea
llnate keys # encrypt a dummy OPENAI_API_KEY into keys.env
llnate push # push → Actions build → operator deploy → prints URLs
curl http://<sha>.agents.127.0.0.1.sslip.io:8080/healthz-
ll-api/ll-operator: build the image, get it into the cluster, restart:docker build -t ll-api:dev ./ll-api # Colima: docker-built images are already visible to k3s # kind: kind load docker-image ll-api:dev --name layernetes # kiac: push to the in-cluster Gitea registry and reference that image kubectl -n layernetes set image deploy/ll-api ll-api=ll-api:dev
-
llnate: pure client-side; run from source against yourLLNATE_API_URL. -
Helm charts:
helm upgrade layernetes ./ll-infra -n layernetes -f ll-infra/values-local.yaml.
- Builds can't push to / cluster can't pull from the Gitea registry. The registry is plain HTTP locally; containerd must trust it as insecure. k3s reads
/etc/rancher/k3s/registries.yaml— mapgitea.127.0.0.1.sslip.io:8080tohttp://gitea-http.layernetes.svc:3000. On Colima, edit inside the VM (colima ssh) andcolima restart; on kind, usecontainerdConfigPatches. - Actions runner idle / jobs queued. The runner registers against Gitea at startup; if Gitea wasn't ready,
kubectl -n layernetes rollout restart deploy/gitea-act-runner. Image builds need its docker-in-docker sidecar running too. sslip.iodoesn't resolve. Some corporate DNS blocks wildcard DNS; fall back to/etc/hostsentries ornip.io.- Bulk transfers stall on kiac (image pulls/pushes hang after a few MB, small requests fine). The Apple vmnet stack mishandles TSO/GSO super-frames on forwarded pod traffic — node-local TCP is unaffected, which makes it look like a registry or ingress bug.
hack/kiac-net-offload-fix.shdisables NIC offloads on every node (measured: stalled → ~600 MB/s). Not persistent across VM restarts. - Out of resources (Colima).
colima stop && colima start --kubernetes --cpu 6 --memory 12— cluster state survives. - Port-forward drops. It's not resilient; rerun it, or use the ingress service's NodePort with the VM IP for something longer-lived.
Copyright © 2026 Sanscourier.ai. All rights reserved.
Layernetes was developed by Sanscourier.ai, which licenses it to Learning Layer for operating the Learning Layer cloud, and releases the source to the public under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). Under the AGPL you may use, study, modify, and redistribute Layernetes — but if you run a modified version as a network service, you must offer its source to the users of that service under the same license.
Sanscourier.ai retains all rights not expressly granted by the AGPL, including the exclusive right to offer Layernetes under alternative commercial terms. For commercial licensing, contact business@sanscourier.ai. Contributions are accepted by application only — see CONTRIBUTING.md.