Skip to content

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:

  1. ssh-keygen -t ed25519 -f ~/.ssh/claude-agents -C claude-agents.
  2. Put the content of ~/.ssh/claude-agents.pub into every session's ssh.authorizedKeys, replacing any other line, and merge.
  3. In the Host *.ssh.wlkr.ch block of ~/.ssh/config:

      IdentityFile ~/.ssh/claude-agents
      IdentitiesOnly yes
    

First login

Once per session, after the first start:

  1. ssh <session>.ssh.wlkr.ch.
  2. 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:

  1. GitHub → Settings → Developer settings → Fine-grained tokens → Generate. A session that gains a repository keeps its token: edit it and add the repository.
  2. Resource owner JanWelker; Only select repositories, and pick the session's repos plus claude-skills.
  3. 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 it
    Actions, Checks, Commit statuses Read gh run and gh pr checks
    Metadata Read Mandatory, added automatically

    The same permissions cover claude-skills: the session pushes skill changes there like in its own repositories.

  4. 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.

  5. Put the token into kv/claude-<session>/github (see above).

Adding a session

The claude-session skill walks through these steps.

  1. Copy a session directory, for example claude-sitzplan/ to claude-<session>/.
  2. In application.yaml, change metadata.name, session, repos, the access toggles and extraEgressFQDNs, and keep skills.repo and ssh.authorizedKeys (see SSH keys).
  3. Create the token and write it to OpenBao before the merge, so the ExternalSecret resolves on the first sync.
  4. Add the session to Sessions and merge. It answers at <session>.ssh.wlkr.ch once the Application is Healthy; then do the first login.

Removing a session

  1. Delete claude-<session>/ and its row in Sessions, and merge.
  2. 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>, then secret/claude-<session>-tls, which cert-manager creates without that label.
  3. The volume is kept on purpose (Prune=false, helm.sh/resource-policy: keep). Delete pvc/claude-<session> once nothing on it is needed.
  4. Remove the token: make claude-session-pat SESSION=<session> DELETE=1 in 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.selector is immutable, so the sync fails and the old pod keeps labels that neither the chart's policy nor homelab-session selects: its Service has no endpoints and its egress drops. Delete statefulset/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 login works; a setup-token or an API key does not, and the session then shows up nowhere in the apps.