Claude agents¶
Each session is a Claude Code instance that keeps running when the laptop is
closed: a pod with sshd, a tmux session and a volume, reached at
<session>.ssh.wlkr.ch. The same session is also driven from the Claude apps
through Remote Control. The image and the Helm chart are the user's own,
built in the agent-mooring repository, which
Conventions → Official upstream sources only
allows for the same reason as the Flowscape image. This repository holds the
namespace, three ServiceAccounts (two the platform binds to its ClusterRoles,
one the openbao-claude-agents store logs in as) and one directory per
session.
At a glance¶
| Hostname | <session>.ssh.wlkr.ch, a passthrough TLSRoute on apps-gateway that the chart renders; external-dns publishes it |
| Authentication | SSH public keys only; no password, no root |
| Storage | One PVC per session: $HOME, the clone, the Claude login and the conversation history |
| Database | None |
| Secrets | kv/claude-<session>/github and kv/claude-agents/argocd in OpenBao |
| Files | claude-agents/ for the namespace; one claude-<session>/ per session, see Sessions |
Sessions¶
| Session | Repositories | Cluster access | Why it is its own session |
|---|---|---|---|
homelab |
homelab, homelab-apps, agent-mooring, flowscape, knead-time, fest.wollbi.ch, advent.wollbi.ch, renovate-config |
read, Argo CD, Prometheus, Hubble, OpenBao; the Internet but not the LAN | Everything that runs on or ships to the cluster, so one agent sees both halves of a rollout |
sitzplan |
sitzplan.schlumpf.me |
none | Hosted outside the cluster; needs only npm besides GitHub |
virgil |
virgil |
none | An iOS app: the agent edits and opens pull requests, CI builds, because there is no Xcode on Linux |
Configuration¶
| Setting | Why |
|---|---|
claude-agents/ is plain manifests; each session is a separate Application on the OCI chart |
The namespace outlives any one session. Removing a session must not prune the namespace or the other sessions |
Session Applications have a single source and no directory.exclude |
exclude filters a Git directory source. An OCI chart has none, so there is nothing for application.yaml to be excluded from |
The namespace enforces restricted |
The image runs as uid 1000 with every capability dropped and a read-only root filesystem |
claude-reader and claude-writer are created here with automountServiceAccountToken: false |
The platform binds them to the read and write ClusterRoles. A pod gets a token only by setting kubernetes.access, which keeps the choice visible in the session's own file |
kubernetes.access, per session |
Only the cluster session has a token, and a read-only one: it investigates and opens pull requests; the cluster changes through Git and ArgoCD, not through the agent. The sessions hosted elsewhere set no access |
argocd.enabled, per session |
On for the cluster session only. Lets the agent read applications with the claude account's token instead of guessing from the Git side. The platform binds that account to role:readonly, so it cannot sync. Through argo-grpc it also needs the homelab-session rule to the server pod; the chart's name rule alone gets a 403 (agent-mooring#3) |
repos set per session |
Clones each repository once into $HOME and creates the GitHub token ExternalSecret; one token covers the list. Without repositories or skills.repo there is no clone, no token and no GitHub egress |
skills.repo: JanWelker/claude-skills |
The private repository behind the laptop's ~/.claude/skills, cloned into the same place and pulled at every session start, so a session runs the same skills and can push changes to them |
appa.enabled: true on claude-homelab |
The session reads cluster state, secrets paths and private repositories and can push and post to GitHub. OpenAPPA checks each tool call against what the session has read, so private data does not flow to public sinks. Only the tmux main session (and its Remote Control) runs under clappa; ACP and hand-started claude are not gated. Tune the policy with /appa-guide in the session, then copy it into appa.policy with the appa-sync skill: the pod installs that file over ~/.config/appa/appa.toml on every start, so a change made only on the PVC lasts until the next restart |
extraEgressFQDNs per session |
A session reaches the package registries its own build needs, and no others |
targetRevision is the only version |
The chart's appVersion is the Claude Code version and its packaged values pin the image by digest, so a Claude Code or base-image release is a chart release and a re-pushed tag cannot change a running session |
TLS ends in a proxy sidecar from the same image, with a per-session Certificate |
Cilium's Gateway only passes TLSRoute traffic through, it cannot terminate it; sshd itself listens on loopback only |
resources in each session's values |
An agent idles between prompts at a few hundred MiB; the chart's default request does not fit beside the rest of the workers. The limit leaves room for builds and subagents |
ssh.authorizedKeys holds the laptop's one key, the same in every session |
The listener is on the public Gateway, so a key is a login from the Internet, and this one opens every session, the one with the homelab write token included. Public keys are not secret; the private one is guarded like the laptop itself |
Network policy¶
Every rule in
claude-agents/networkpolicy.yaml,
and why it is there. The shape is
Conventions → Ship a CiliumNetworkPolicy;
rollout and debugging are in
Security Policies.
The names an agent may reach live in each session's own policy, rendered by
the chart from its values (extraEgressFQDNs adds to them).
| Rule | Why |
|---|---|
Ingress on 2222 from the ingress entity |
The Gateway passes the TLS stream through by SNI to the pod's TLS sidecar, so the Gateway is the only way in |
Ingress from host and remote-node |
The kubelet's probes, the pair every policy names (Conventions → rule 5) |
| Ingress and egress within the namespace | Sessions share the namespace; the policy does not split them further |
| Default egress to the namespace only | A session reaches nothing else unless its own policy says so. Those names (the Claude API, GitHub when repos or skills.repo is set, the Argo CD server) are the agent's reach, so adding one is reviewed like a firewall change |
homelab-session: every public address, minus the private, carrier-grade and link-local ranges and the router's public address |
The cluster session installs tools, reads docs and fetches whatever a diagnosis needs; a name list would be a firewall change per lookup. Cutting the private ranges keeps the LAN, the nodes' LAN side and the Gateway addresses out; the public address is the external-dns target of apps-gateway, which hairpin NAT would otherwise hand back to the Gateway and every route on it |
homelab-session: Prometheus, Hubble Relay, the Argo CD server and OpenBao |
The APIs the session diagnoses with; each service's own policy admits the session in the platform repository. The Argo CD server pod is needed for argo-grpc too, because the Gateway checks the caller's egress against the backend |
Usage¶
SSH config¶
One block covers every session, because the hostname is the only thing that differs:
Host *.ssh.wlkr.ch
User agent
ProxyCommand openssl s_client -quiet -verify_return_error -servername %h -connect %h:443
sshd admits only the user agent. Add the IdentityFile from
SSH keys. Then ssh homelab.ssh.wlkr.ch attaches to the tmux session main. With a
command, ssh homelab.ssh.wlkr.ch <command> runs it directly, so scp and
rsync work.
SSH keys¶
One key pair for the sessions, generated on the laptop and used for nothing else:
ssh-keygen -t ed25519 -f ~/.ssh/claude-agents -C claude-agents.- Put the content of
~/.ssh/claude-agents.pubinto every session'sssh.authorizedKeys, replacing any other line, and merge. -
In the
Host *.ssh.wlkr.chblock of~/.ssh/config:
First login¶
Once per session, after the first start:
ssh <session>.ssh.wlkr.ch.- Claude Code asks how to log in: pick Claude account with subscription and finish the browser flow.
The login lives on the volume and survives restarts. Remote Control is on for
every session through the managed settings the chart renders into the
session's ConfigMap; the session appears in the Claude apps as
claude-<session>.
Secrets¶
None is generated: each comes from an account outside the cluster. In the
platform repository, make claude-session-pat SESSION=<session> writes a
session's token and make bao-secrets the shared Argo CD one. Both are read
through the openbao-claude-agents store (secretStore in each session's
values), which reads kv/claude-* and nothing else: the session paths sit
outside the namespace's own prefix, so openbao-apps cannot reach them — see
Kubernetes auth.
| Path | Key | Read as |
|---|---|---|
kv/claude-<session>/github |
token |
GH_TOKEN: the fine-grained token for that session's repositories and skills.repo. Only when either is set |
kv/claude-agents/argocd |
token |
The Argo CD claude account's API token, from argocd account generate-token --account claude. Only when argocd.enabled |
A rotated token needs no pod restart: the chart mounts both as files and the
gh, git and argocd wrappers read them on every call. The
ExternalSecret refreshes hourly, so force-sync it for an immediate switch;
a login shell opened before keeps the old GH_TOKEN in its environment.
Creating the GitHub token¶
A fine-grained personal access token, one per session, so a leak costs that session's repositories and the shared skills only:
- GitHub → Settings → Developer settings → Fine-grained tokens → Generate. A session that gains a repository keeps its token: edit it and add the repository.
- Resource owner
JanWelker; Only select repositories, and pick the session'sreposplusclaude-skills. -
Repository permissions:
Permission Access Why Contents Read and write Clone and push branches Pull requests Read and write Open and update pull requests Workflows Read and write A push that touches .github/workflows/is rejected without itActions, Checks, Commit statuses Read gh runandgh pr checksMetadata Read Mandatory, added automatically The same permissions cover
claude-skills: the session pushes skill changes there like in its own repositories. -
Set an expiry and put the date in a calendar: the session reports an expired token only at its next start, and tokens created on the same day expire together.
- Put the token into
kv/claude-<session>/github(see above).
Adding a session¶
The claude-session skill walks through these steps.
- Copy a session directory, for example
claude-sitzplan/toclaude-<session>/. - In
application.yaml, changemetadata.name,session,repos, the access toggles andextraEgressFQDNs, and keepskills.repoandssh.authorizedKeys(see SSH keys). - Create the token and write it to OpenBao before the merge, so the
ExternalSecretresolves on the first sync. - Add the session to Sessions and merge. It answers at
<session>.ssh.wlkr.chonce the Application is Healthy; then do the first login.
Removing a session¶
- Delete
claude-<session>/and its row in Sessions, and merge. - The ApplicationSet preserves resources on deletion, so remove them by
hand:
kubectl -n claude-agents delete all,configmap,externalsecret,certificate,secret,tlsroute,ciliumnetworkpolicy,serviceaccount -l app.kubernetes.io/instance=claude-<session>, thensecret/claude-<session>-tls, which cert-manager creates without that label. - The volume is kept on purpose (
Prune=false,helm.sh/resource-policy: keep). Deletepvc/claude-<session>once nothing on it is needed. - Remove the token:
make claude-session-pat SESSION=<session> DELETE=1in the platform repository, and revoke it on GitHub.
Every session shares one targetRevision in practice: Renovate groups them
into one pull request.
Sessions without a repository¶
Leave repos and skills.repo empty. The pod then has no clone, no GitHub token
and no GitHub egress, and the kv/claude-<session>/github path is not read.
Use it for a scratch session that only needs the Claude API.
ACP from Zed¶
Set acp.enabled: true in the session's values. Zed then starts the agent
over the same SSH connection (ssh <session>.ssh.wlkr.ch claude-code-acp),
which works because a command bypasses tmux.
Hosted side checklist¶
What lives on Anthropic's and GitHub's side, not in this repository:
- [ ] The Claude GitHub App is installed on all repositories the sessions touch.
- [ ] Auto-fix is enabled for pull requests the sessions open.
- [ ] Cloud environments exist for the repositories that need a hosted run.
- [ ] Routines run the sweeps that need no cluster access: Renovate pull requests and merge conflicts.
Health check¶
kubectl -n claude-agents get pods,statefulsets
kubectl -n claude-agents get tlsroute
openssl s_client -quiet -servername homelab.ssh.wlkr.ch -connect homelab.ssh.wlkr.ch:443 </dev/null 2>&1 | head -1
The last command prints the SSH banner (SSH-2.0-OpenSSH_...) when the whole
path works: DNS, certificate, Gateway, policy and sshd.
Pitfalls¶
- An update restarts the pod and ends the running turn. A new chart
version is a new pod. The conversation survives on the volume: the start
script runs
claude --continue, which resumes it, but a turn in flight is lost. Renovate therefore opens chart bumps only at night and automerges only a patch; merge a minor between turns. - A chart that changes its selector labels cannot update the
StatefulSet.
spec.selectoris immutable, so the sync fails and the old pod keeps labels that neither the chart's policy norhomelab-sessionselects: its Service has no endpoints and its egress drops. Deletestatefulset/claude-<session>and let Argo CD recreate it; the PVC is a separate chart resource and stays. - The merge gate is advisory. The session tokens are the owner's own fine-grained tokens and the rulesets require no approval, so a session can merge its own pull request. Only Claude Code's managed deny rules and OpenAPPA stand in the way until the agents have their own GitHub identity.
- Remote Control needs a full subscription login.
claude auth loginworks; asetup-tokenor an API key does not, and the session then shows up nowhere in the apps.