Run Crew on several remote hosts — dev boxes, EC2 instances, home servers — and drive them all from one hub gateway. Multi-instance opens SSH tunnels to each remote, mints short-lived dashboard tokens, and embeds each remote dashboard in a tab strip. The most-recently-used instances stay warm; the rest reconnect on demand.
Opt-in. Off by default.
kirocrew config set instances.enabled true kirocrew restart
When enabled, the gateway:
SshTunnelManagerframe-src relaxation to the active loopback tunnel ports (so embedded remote dashboards can render — they're otherwise blocked by the strict frame-src 'self' blob:)With the flag off, /api/instances/* returns 403 and the /instances page shows an opt-in hint.
┌── Hub gateway (this host) ──┐ │ /instances page (React) │ │ ├─ tab strip: Home · A · B │ │ ├─ warm <iframe>s per tab │ http://127.0.0.1:<local_port>/?token=… │ └─ Manage panel │ add / connect / diagnose / restart / remove │ │ │ instances/ package │ │ ├─ registry │ ~/.kiro/crew/instances.json │ ├─ port_allocator │ loopback ports from base 7778 │ ├─ token_mint │ ssh <host> kirocrew token → JWT (never logged) │ ├─ ssh_tunnel_manager │ supervised ssh -N -L, probe, self-heal, refresh │ └─ diagnostics │ ssh → remote-dashboard → local-forward ladder └──────────────────────────────┘ │ ssh -N -L 127.0.0.1:<local>:127.0.0.1:<remote> ▼ ┌── Remote gateway ────────────┐ │ kirocrew gateway bound to │ │ 127.0.0.1:<remote_port> │ └──────────────────────────────┘
Each instance you add produces a supervised ssh -N -L child. The tunnel forwards 127.0.0.1:<local> → remote 127.0.0.1:<remote>. The hub mints a dashboard token on the remote over SSH and embeds the remote dashboard in an iframe at the loopback URL.
POST /api/instances/{id}/connect allocates a loopback port, mints a token on the remote over SSH, starts ssh -N -L, waits until the local forward accepts a connection, then returns the live statuswarm_set_cap (default 5) most-recently-used instances stay warm with a live tunnel + WebSocket. Connecting beyond the cap lazily evicts the least-recently-used?diagnose=1 runs a failure-probe ladder on demand; POST .../restart restarts the remote gateway over SSHOpen Instances in the dashboard, click Add, fill in:
| Field | Description |
|---|---|
| Name | Any label (e.g. "Dev box 1") |
| SSH host / alias | What you'd type after ssh — see below |
| Remote port | Remote gateway's port (default 7777) |
| Token TTL | Default 20 h |
The ssh_host field accepts:
dev-1.example.com)ec2-user@10.0.1.5)my-ec2)Any option starting with - is rejected (injection guard).
The only per-remote knob is ssh_host. Anything ssh can reach non-interactively works.
Use your SSH config alias or user@hostname. As long as a key in your ssh-agent covers auth, BatchMode succeeds without prompting.
Configure an SSH alias in ~/.ssh/config on the hub, then reference the alias:
Host my-ec2 HostName ec2-1-2-3-4.compute-1.amazonaws.com User ec2-user IdentityFile ~/.ssh/my-key.pem # Optional: reach a private instance through a bastion ProxyJump bastion-host # Or via SSM Session Manager: # ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters portNumber=%p"
Then add an instance with my-ec2 as the SSH host.
Prerequisites on the hub:
ssh-agent holding it (BatchMode won't prompt)kirocrew installed and a gateway running on the EC2 instance's loopback port| Need | Status | How |
|---|---|---|
| Custom login user | ✅ | user@host or ssh-config User |
| FQDN / IP | ✅ | direct ssh_host value |
Identity file (-i) | ⚠️ via ssh config only | IdentityFile in a Host block |
| Non-22 SSH port | ⚠️ via ssh config only | Port in a Host block |
| Bastion / ProxyJump | ⚠️ via ssh config only | ProxyJump / ProxyCommand |
| SSM-only instances | ⚠️ via ssh config only | ProxyCommand with aws ssm start-session |
The Manage panel gives you these per-instance actions:
Every route in /api/instances/* is gated by _guard():
request["user"] (authenticated dashboard session)instances.enabled: trueBeyond the guard:
ssh -N -L 127.0.0.1:<local>:127.0.0.1:<remote>ssh is always invoked with an argv list; ssh_host cannot inject local shell syntaxssh_host and remote_bin rejected if they contain shell metacharacters or lead with -event.origin against the exact http://127.0.0.1:<port> of a currently-warm tunnel before trusting an unread-count messageUnder instances.*:
| Key | Default | Meaning |
|---|---|---|
instances.enabled | false | Master opt-in |
instances.warm_set_cap | 5 | Max instances kept warm at once |
instances.tunnel_base_port | 7778 | First local loopback port for ssh -L |
instances.max_recovery_attempts | 8 | Consecutive self-heal attempts before giving up |
instances.recover_backoff_max_secs | 30 | Cap on exponential backoff between self-heal attempts |
instances.probe_failure_threshold | 3 | Consecutive probe failures before triggering recovery |
| Symptom | Fix |
|---|---|
/instances shows "multi-instance management is off" | instances.enabled is false — set it and restart |
| Iframe is blank | CSP frame-src relaxation only applies to active tunnel ports; ensure the instance is connected |
| Connect fails with SSH auth error | Refresh your SSH credentials (re-add key to ssh-agent); tunnels self-heal once SSH is restored |
| Connect fails | Use Diagnose — the ladder reports the first broken link (ssh_unreachable, remote_down, or tunnel_down) |
| Instance keeps dropping | Health probe + 2-tier self-heal retry over ~2 min; if it gives up, diagnosis runs automatically. Check the remote gateway and SSH stability |
| An instance silently disappeared from the warm set | LRU-evicted (warm set full). Raise instances.warm_set_cap or reconnect on demand |
Multi-instance