# Specialists

A specialist is a Pi subagent that is the only way to operate one area of the homelab. It has one tool, one skill, and one Vaultwarden note. Every other agent is blocked from the area's CLIs and credentials.

| Agent | Area | What it can do |
|---|---|---|
| `astro.arcane` | Arcane container platform (VM 100) and its GitOps repository | Every `arcane-cli` operation except its own `config`, `auth`, `self-update`; Compose changes of GitOps-managed projects through `git show|write|commit|push`, then `arcane-cli gitops` sync |
| `astro.identity` | Zitadel; Pomerium, nginx, lego on VM 104; `02-pulsar-proxmox/dmz/` in the homelab repository | Any Zitadel API call (v2, management, admin, auth) with responses redacted; `dmz status|journal|config-template|render|pomerium-restart|nginx-reload|lego-renew|deploy-config`; `git` on the homelab checkout, writes limited to `02-pulsar-proxmox/dmz/` |
| `astro.network` | UniFi, FreeRADIUS (LXC 108), Tailscale (LXC 105) | Read UniFi sites, devices, clients, networks, zones, firewall policies, WANs; create, update, delete firewall policies; RADIUS and Tailscale checks on Pulsar |
| `astro.dns` | Technitium primary (Synapse) and secondary (LXC 107); Cloudflare zones `37pla.net`, `monadeo.com` | Read zones and records on both resolvers, resolve, stats; create and delete zones, add, update, delete records on the primary; Cloudflare zones and record add, update, delete |
| `astro.edge` | Frontdoor VPS (`frontdoor-1337`) over an IAP tunnel; `00-frontdoor-vps/` in the homelab repository | `probe` the public path; `vps status|nginx-conf|nginx-test|journal|wg|bouncer|updates|deploy-nginx|reboot --confirm`; `git` on the homelab checkout, writes limited to `00-frontdoor-vps/` |
| `astro.security` | Wazuh, CrowdSec, rsyslog archive on VM 106 | Read-only: `status`, `attention`, `agents`, `alerts`, `decisions`, `bouncers`, `metrics`, `wazuh-alerts`, `wazuh-log`, `remote-hosts`, `remote-log`, `journal` |
| `astro.backup` | vzdump, the shared Restic repository (Pulsar host config, Arcane app backups), GCS offsite from LXC 102 | Status, journals, and manual runs of every layer; `restic-snapshots|stats|ls`, `restic-restore` into `/var/tmp/spc-restore` on Pulsar, `restore-clean` |
| `astro.proxmox` | Proxmox VE on Pulsar | API token: `guests`, `guest`, `get <path>`, `tasks`; `start`, `shutdown|reboot|stop --confirm`; snapshots create, delete, rollback; `set` of CPU, memory, options, network. Pulsar key: `host-status`, `host-journal`, `updates`, `guest-exec <vmid> <status|journal|df|updates>` |
| `astro.inference` | llama.cpp on Nexus, Hermes on LXC 101, Europa health | `nexus-status|start|stop|restart|log|disk`; `hermes-status|journal|errors|restart|api-health|version|guest-status|snapshots`, `hermes-releases`, `hermes-upgrade --confirm`; `europa-health` |

Call one with `/run astro.<name> -- <task>` or through the `subagent` tool (both from the bundled `astro-subagents` extension). `/run` saves the specialist's session under `~/.pi/agent/subagent-sessions/<agent>/`; `/run astro.<name> --continue -- <task>` resumes the last one started from the same directory, so the specialist keeps what it saw. Sessions older than 7 days are deleted. Specialists may call each other once (`maxSubagentDepth: 1`). They run on `openai-codex/gpt-6-astra` with `xhigh` thinking, set by `model` and `thinking` in each `agents/<name>.md`; an agent without these fields inherits the caller's model and thinking. Each tool takes `args`, an array of strings; `["--help"]` lists the subcommands.

## How it is secured

- Credentials and CLIs belong to a dedicated macOS user `specialist` on the host. Agents run as the main user and reach the wrappers only through `sudo -n -u specialist -H /usr/local/bin/specialist-cli`, the single passwordless sudo rule. General sudo asks for a password.
- Credentials come from Vaultwarden (`homelab` / `specialists`, one Secure Note per specialist) through `specialist-cli provision`, into `0600` files. Wrappers never call `bw`.
- Arcane is reached with a Zitadel service user: a fresh token per call, exchanged for a 300 s Arcane token. Other services use API keys, a PAT, tokens, or SSH keys that the targets pin to forced commands. The VPS is reached through `gcloud compute start-iap-tunnel` with a service account that may only open IAP tunnels and read instances; its gcloud state lives in `/Users/specialist/.specialists/work/gcloud`.
- Every call is logged in `/Users/specialist/.specialists/audit.log`.
- The `specialist-gate` extension registers the tool only inside the matching specialist child and blocks other agents from calling the CLIs or reading `/Users/specialist`. `security-guard` carries the same blocks.

Limits: `network` writes only firewall policies on UniFi. `security` is read-only. `identity` deploys only `config.yaml` to the DMZ. `edge` changes only `nginx.conf` on the VPS; `dns` writes only to the Technitium primary. `proxmox` cannot create, clone, or destroy guests, change disks, or touch the host. `backup` cannot change retention or schedules. `inference` cannot start Europa or change models.

## Enable specialists on a host

Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14`, git). Steps 1 to 3 are one-time on the services; 4 to 8 are per host.

1. **Vaultwarden.** Account `specialists@monadeo.com` with **Can view** on collection `specialists` in organization `homelab`. Secure Notes with Hidden fields (`_B64` fields hold `base64 -i <keyfile> | tr -d '\n'`):
   - `arcane`: `ZITADEL_KEY_JSON`, `GITOPS_DEPLOY_KEY_B64`, `ZITADEL_USER_ID` (plain).
   - `identity`: `ZITADEL_PAT`, `SSH_KEY_DMZ_B64`, `HOMELAB_DEPLOY_KEY_B64`.
   - `network`: `UNIFI_API_KEY`, `SSH_KEY_PULSAR_B64`.
   - `dns`: `TECHNITIUM_TOKEN`, `TECHNITIUM2_TOKEN` (Technitium API tokens of the primary and the secondary), `CLOUDFLARE_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`.
   - `edge`: `GCP_SA_KEY_JSON` (service account key), `SSH_KEY_FRONTDOOR_B64`, `HOMELAB_DEPLOY_KEY_B64`.
   - `security`: `SSH_KEY_OBS_B64`.
   - `backup`: `SSH_KEY_PULSAR_B64`, `SSH_KEY_ARCANE_B64`.
   - `proxmox`: `PVE_TOKEN_ID` (plain, `spc-proxmox@pve!cortex`), `PVE_TOKEN_SECRET`, `SSH_KEY_PULSAR_B64`.
   - `inference`: `SSH_KEY_NEXUS_B64`, `SSH_KEY_HERMES_B64`, `SSH_KEY_PULSAR_B64`.
2. **Identities.** Zitadel (`https://auth.37pla.net/ui/console`): service user `spc-arcane` with Access Token Type JWT and a JSON key; service user `spc-identity` with a Personal Access Token and the Instance Owner role. Technitium (both resolvers): a user with Zones View/Modify/Delete, Dashboard View, DNS Client View, and an API token each. Cloudflare: an account-owned API token with `Zone:Read` and `DNS:Edit` on both zones. GCP project `frontdoor-1337`: service account `spc-edge` with `roles/iap.tunnelResourceAccessor` and `roles/compute.viewer`, JSON key. Proxmox (`ssh pulsar`): user `spc-proxmox@pve`, role `SpecialistGuest` = `VM.Audit VM.PowerMgmt VM.Snapshot VM.Snapshot.Rollback VM.Config.CPU VM.Config.Memory VM.Config.Options VM.Config.Network VM.Config.CDROM VM.GuestAgent.Audit`, ACLs `PVEAuditor` on `/` and `SpecialistGuest` on `/vms` for both the user and the token `spc-proxmox@pve!cortex` (`-privsep 1`).
3. **Services.** Arcane (Settings, Authentication, Federated Credentials): Issuer `https://auth.37pla.net`, Audience `387825541182390275`, Subject exact = `spc-arcane` user ID, Role Admin, Global, lifetime 300. UniFi: API key (Settings, API Keys) and policies allowing Cortex `10.0.40.37` to TCP 22 on `10.0.20.10`, `10.0.10.37`, `10.0.90.10`, TCP 5380 on `10.0.40.53`, `10.0.40.54`, TCP 8006 on `10.0.10.37`, and TCP 8081 on `10.0.60.5`. GitHub: `spc-arcane-gitops` as a write deploy key on `astronaute77/arcane`, `spc-edge-homelab` and `spc-identity-homelab` as write deploy keys on `astronaute77/homelab`.
4. **Sudo and user**, as an administrator:
   ```sh
   printf 'cortex ALL=(ALL) ALL\n' | sudo tee /etc/sudoers.d/cortex >/dev/null
   printf 'cortex ALL=(specialist) NOPASSWD: /usr/local/bin/specialist-cli\n' | sudo tee /etc/sudoers.d/specialists >/dev/null
   sudo chmod 0440 /etc/sudoers.d/cortex /etc/sudoers.d/specialists && sudo rm -f /etc/sudoers.d/cortex-nopasswd && sudo visudo -c
   sudo sysadminctl -addUser specialist -fullName "Specialists" -home /Users/specialist -shell /bin/zsh -password -
   sudo dscl . create /Users/specialist IsHidden 1 && sudo createhomedir -c -u specialist && sudo chmod 700 /Users/specialist
   sudo install -m 0755 -o root -g wheel <package>/specialists/entry/specialist-cli.sh /usr/local/bin/specialist-cli
   brew install --cask gcloud-cli
   ```
   Add `cortex ALL=(specialist) NOPASSWD: ALL` as `/etc/sudoers.d/specialists-setup` (mode `0440`) for the duration of steps 5 to 7, then delete it.
5. **Bootstrap**, as `specialist` (`sudo -u specialist -H zsh`, `umask 077`): `~/.config/specialists/client.env` with `BW_SERVER=https://vault.37pla.net`, `BW_CLIENTID`, `BW_CLIENTSECRET`; `~/.config/specialists/master-password`. Both `0600`.
6. **Install and configure**, as `specialist`: `sh <package>/specialists/install/install.sh <package> 2.12.0` (`<package>` is the installed pi-astro directory, `~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro`). Write `~/.specialists/config/<name>.json` for every specialist:
   ```json
   { "serverUrl": "http://10.0.40.30:3552", "zitadelDomain": "auth.37pla.net", "audience": "387825541182390275",
     "arcaneCli": "/Users/specialist/.specialists/bin/arcane-cli", "arcaneCliConfig": "/Users/specialist/.specialists/config/arcanecli.yml",
     "gitopsRemote": "git@github.com:astronaute77/arcane.git", "gitopsBranch": "main" }
   { "zitadelDomain": "auth.37pla.net", "dmzHost": "10.0.20.10", "dmzUser": "dmz",
     "homelabRemote": "git@github.com:astronaute77/homelab.git", "homelabBranch": "main", "repoArea": "02-pulsar-proxmox/dmz", "pomeriumConfig": "02-pulsar-proxmox/dmz/config.yaml" }
   { "unifiBaseUrl": "https://10.0.10.1/proxy/network/integration", "unifiCertSha256": "<from: specialist-cli network -- unifi fingerprint>", "pulsarHost": "10.0.10.37", "pulsarUser": "root" }
   { "technitiumPrimaryUrl": "http://10.0.40.53:5380", "technitiumSecondaryUrl": "http://10.0.40.54:5380", "technitiumCatalog": "catalog.invalid" }
   { "gcloud": "/opt/homebrew/bin/gcloud", "gcloudPython": "/opt/homebrew/opt/python@3.14/bin/python3.14",
     "project": "frontdoor-1337", "zone": "us-east1-c", "instance": "frontdoor-1337", "serviceAccount": "spc-edge@frontdoor-1337.iam.gserviceaccount.com",
     "iapLocalPort": 22337, "sshUser": "spc-edge", "publicIp": "35.237.66.101", "probeHost": "auth.37pla.net",
     "homelabRemote": "git@github.com:astronaute77/homelab.git", "homelabBranch": "main", "repoArea": "00-frontdoor-vps", "nginxConf": "00-frontdoor-vps/nginx.conf" }
   { "obsHost": "10.0.90.10", "obsUser": "observability" }
   { "pulsarHost": "10.0.10.37", "pulsarUser": "root", "arcaneHost": "10.0.40.30", "arcaneUser": "arcane" }
   { "apiUrl": "https://10.0.10.37:8006", "certSha256": "<from: specialist-cli proxmox -- fingerprint>", "node": "pulsar", "pulsarHost": "10.0.10.37", "pulsarUser": "root" }
   { "nexusHost": "10.0.40.38", "nexusUser": "nexus", "hermesHost": "10.0.40.31", "hermesUser": "hermes", "pulsarHost": "10.0.10.37", "pulsarUser": "root",
     "europaHealthUrl": "http://10.0.60.5:8081/health", "hermesRepo": "NousResearch/hermes-agent" }
   ```
   Then `sudo -n -u specialist -H /usr/local/bin/specialist-cli provision`; it lists every note and field.
7. **SSH targets.** Each target gets an entry script from `entry/remote/` as `/usr/local/sbin/<entry>` (`0755`, root) and the public key in the right `authorized_keys` as
   `command="/usr/local/sbin/<entry>",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 ...`:
   - VM 104, user `dmz`: `spc-identity-dmz-entry.sh`.
   - Pulsar, root: `spc-network-pulsar-entry.sh`.
   - VM 106, user `observability`: `spc-security-obs-entry.sh` (tracked in the homelab repository, installed by `02-pulsar-proxmox/observability/deploy.sh`).
   - Frontdoor VPS: `spc-edge-frontdoor-install.sh` creates user `spc-edge` (group `systemd-journal`), installs `spc-edge-frontdoor-entry.sh` and the sudoers file `spc-edge-frontdoor-sudoers`, and writes `authorized_keys`. Copy the three files and the public key to `/tmp` with `gcloud compute scp --tunnel-through-iap`, then run it as root.
   - Pulsar, root, three more keys: `spc-backup-pulsar-entry.sh`, `spc-proxmox-pulsar-entry.sh`, `spc-inference-pulsar-entry.sh` (tracked in `02-pulsar-proxmox/pulsar/system/`).
   - VM 100, user `arcane`: `spc-backup-arcane-entry.sh` (tracked as `host/spc-backup-arcane-entry` in the `astronaute77/arcane` repository, next to the host backup scripts).
   - Nexus, user `nexus`: `spc-inference-nexus-entry.sh` as `/usr/local/sbin/spc-inference-nexus-entry` (tracked in `04-nexus-macstudio/system/`).
   - LXC 101, user `hermes`: `spc-inference-hermes-entry.sh` (tracked in `02-pulsar-proxmox/hermes/system/`).
8. **Pi**, as the main user: `~/.pi/agent/specialists.json` (below) and the specialist blocks in `~/.pi/agent/security-guard.json` (`specialist-cli`, `arcane-cli`, `-u specialist` in `operations`; `/Users/specialist/**` in `reads` and `writes`; keep the default rules next to them).
   ```json
   { "entry": "/usr/local/bin/specialist-cli", "user": "specialist",
     "specialists": [
       { "agent": "astro.arcane", "tool": "arcane", "service": "arcane", "timeoutMs": 300000 },
       { "agent": "astro.identity", "tool": "identity", "service": "identity", "timeoutMs": 120000 },
       { "agent": "astro.network", "tool": "network", "service": "network", "timeoutMs": 120000 },
       { "agent": "astro.dns", "tool": "dns", "service": "dns", "timeoutMs": 60000 },
       { "agent": "astro.edge", "tool": "edge", "service": "edge", "timeoutMs": 300000 },
       { "agent": "astro.security", "tool": "security", "service": "security", "timeoutMs": 120000 },
       { "agent": "astro.backup", "tool": "backup", "service": "backup", "timeoutMs": 3600000 },
       { "agent": "astro.proxmox", "tool": "proxmox", "service": "proxmox", "timeoutMs": 360000 },
       { "agent": "astro.inference", "tool": "inference", "service": "inference", "timeoutMs": 1260000 } ] }
   ```

Check: `sudo -n -u specialist -H /usr/local/bin/specialist-cli arcane --caller test -- projects list` prints JSON; the same for `identity -- dmz status`, `network -- unifi sites`, `dns -- technitium primary zones`, `edge -- vps status`, `security -- attention`, `backup -- vzdump-tasks`, `proxmox -- guests`, `inference -- nexus-status`; `/run astro.arcane -- list projects` works in Pi; `bash` with `arcane-cli --help` is blocked in a normal session.

## Discord

The `astro-discord` extension gives Discord users access to the specialists from a channel. It runs in a headless Pi host on Cortex (LaunchAgent `com.astrofoundry.astro-discord`, user `cortex`), never in interactive sessions.

- Channels are configured one by one (`channels.<id>`): `trigger` is `always` (every message that names a specialist counts) or `mention` (only messages that start with `@Cortex` or reply to the bot); `specialists` is `*` or a list. A channel with one specialist needs no prefix: every message there is a task for it. In a channel with several, `dns list the zones` names the specialist; an addressed message without a name gets a reply asking for one. Threads inherit their parent channel's entry; unlisted channels are ignored. The bot replies at once with "<specialist> is working on your task" and shows the typing indicator until the answer replaces that reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows the specialists you may use in that channel and the commands (`status` lists running tasks; owners in the admin channel also see the `config` commands). Each channel and specialist has one conversation: a reply to the bot's answer, or another task for that specialist within 30 minutes, continues it with everything the specialist saw before; otherwise a new one starts, and a task beginning with `new` forces that. Replied-to and forwarded messages (text, embeds, attachment names) travel with the task, so "check this" on a forwarded alert works. Image attachments on the message or on what it quotes (up to 4, 8 MiB each) are downloaded and passed to the specialist, which sees them; other file types are named only. Sessions live under `~/.config/astro-discord/sessions/<specialist>/` and are deleted after 7 days. Long answers arrive as a `.md` attachment.
- Access: `owners` may use every specialist and decide approvals; `access.<specialist>` lists extra users for that specialist; everyone else gets no reply. Owners change the configuration from the admin channel (`adminChannelId`) without a shell: `config show`, `config channel <#channel> <always|mention> <*|dns,edge>`, `config channel <#channel> remove`, `config access <specialist> add|remove <@user>`; the bot writes `~/.config/astro-discord/config.json` on Cortex, which is also editable by hand and re-read on every message. Deny the bot's role View Channel on channels it must never read; that is the boundary the map cannot provide.
- Approvals: a risky call (anything with `--confirm`, plus writes such as record or zone deletes, Zitadel changes, Pomerium deploys, Arcane redeploys, service restarts) pauses the specialist and posts Approve/Deny buttons; only owners' clicks count; no decision within `approvalTimeoutMinutes` denies it. The specialist then reports the denial. Ambiguous tasks get a question back instead of an action.
- Setup, as `cortex`: `d=$(mktemp -d) && cp ~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro/extensions/astro-discord/*.ts "$d" && node --disable-warning=ExperimentalWarning "$d/setup.ts"` (Node does not strip types inside `node_modules`, so the files are copied first) prompts for the bot token and the ids, checks each against Discord, and writes `~/.config/astro-discord/{token,config.json,run.sh}` and the LaunchAgent; it prints the `launchctl bootstrap` line. After a pi-astro update restart the bridge with `launchctl kickstart -k gui/$(id -u)/com.astrofoundry.astro-discord`. Log: `~/Library/Logs/astro-discord.log`.
- Discord side: application `Cortex` with a bot, Message Content Intent enabled, invited with scopes `bot` and `applications.commands` and permissions View Channel, Send Messages, Attach Files, Read Message History, Add Reactions.

## Rotation and operations

Rotation steps for every field live in the homelab repository, `03-cortex-macmini/ROTATION.md`. After any change in Vaultwarden run `specialist-cli provision`. Host state and the sudo rules are documented in `03-cortex-macmini/README.md`; the forced-command scripts are tracked under `02-pulsar-proxmox/dmz/system/`, `02-pulsar-proxmox/pulsar/system/`, `02-pulsar-proxmox/observability/system/`, `02-pulsar-proxmox/hermes/system/`, `host/` of the `astronaute77/arcane` repository, `04-nexus-macstudio/system/`, and `00-frontdoor-vps/system/`.

All nine planned specialists exist. See `AGENTS.md` for how to add one.
