# 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`](<https://github.com/JanWelker/agent-mooring>) repository, which [Conventions → Official upstream sources only](<https://homelab-apps.wlkr.ch/conventions/#2-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/`](<https://github.com/JanWelker/homelab-apps/tree/main/claude-agents>) for the namespace; one `claude-<session>/` per session, see [Sessions](<#sessions>) |

## Sessions

| Session | Repositories | Cluster access | Why it is its own session |
| --- | --- | --- | --- |
| [`homelab`](<https://github.com/JanWelker/homelab-apps/tree/main/claude-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`](<https://github.com/JanWelker/homelab-apps/tree/main/claude-sitzplan>) | `sitzplan.schlumpf.me` | none | Hosted outside the cluster; needs only npm besides GitHub |
| [`virgil`](<https://github.com/JanWelker/homelab-apps/tree/main/claude-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](<https://github.com/JanWelker/agent-mooring/issues/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](<https://openappa.com>) 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`](<https://github.com/JanWelker/homelab-apps/blob/main/claude-agents/networkpolicy.yaml>), and why it is there. The shape is [Conventions → Ship a `CiliumNetworkPolicy`](<https://homelab-apps.wlkr.ch/conventions/#5-ship-a-ciliumnetworkpolicy>); rollout and debugging are in [Security Policies](<https://homelab.wlkr.ch/platform/security-policies/#network-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](<https://homelab-apps.wlkr.ch/conventions/#5-ship-a-ciliumnetworkpolicy>)) |
| 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:

```text
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](<#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`:

   ```text
     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](<https://homelab.wlkr.ch/platform/openbao/#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](<#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](<#sessions>) and merge. It answers at `<session>.ssh.wlkr.ch` once the Application is Healthy; then do the [first login](<#first-login>).

### Removing a session

1. Delete `claude-<session>/` and its row in [Sessions](<#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

```bash
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.
