Una sesión de agente que sobrevive a cerrar la laptop, corriendo en una sola instancia EC2 chica que manejas desde el teléfono. Este post cubre tanto Claude Code como Kiro CLI, porque resuelven el problema de "alcanzar la sesión desde otro lado" de maneras completamente distintas, y esa diferencia decide la mayor parte de tu arquitectura.
| Versión ingenua | Después | |
|---|---|---|
| Las credenciales viven en | el volumen raíz de la instancia | Secrets Manager |
| Sobrevive al reemplazo de la instancia | no, re-login manual | sí, sin atender (~2 min) |
| Tiempo para notar una caja muerta | ||
| 6 días (a mano) | ||
| 15 min (alarma) | ||
| Clone fallido al arrancar | tragado por `\ | \ |
| Ciclo de vida acoplado a | el stack de producción | su propio stack |
El costo es una instancia ARM on-demand chica más un volumen gp3 de 30 GB: revisa el precio de lista actual de tu región, pero es la parte más barata de todo esto.
Una instancia chica siempre encendida lo resuelve, con dos condiciones:
puedes alcanzar la sesión desde donde estés, y la caja regresa por su
cuenta cuando la infraestructura se mueve por debajo de ella. La segunda es de lo que este post trata en realidad.
Esta es la bifurcación del camino, así que hazla bien antes de construir nada.
Claude Code tiene una sesión remota de primera mano. {% raw %}claude
registra la sesión en curso con un punto de encuentro
remote-control
hospedado, y la manejas desde claude.ai/code
o la app móvil. La caja hace una conexión de salida nada más:
claude remote-control --name myapp-cloud --continue
--continue
reanuda la misma sesión a través de reinicios del proceso, así que un enlace en marcadores se queda estable cuando el servicio rebota.
Esto necesita un CLI reciente: la caja de este post corría la 2.1.211. Al tener éxito imprime:
Take this session with you and pick up right where you left off on any device.
Open the Code tab in the Claude mobile app, or visit claude.ai/code in a browser.
The session keeps running on this machine. Use your other devices as a remote control. Press Ctrl+C to stop.
La consecuencia de seguridad es grande: sin puertos de entrada, sin SSH, sin listener público. El security group puede ser solo de salida.
Kiro CLI no tiene equivalente. Sus docs describen dos modos, ninguno de los cuales es una sesión remota:
kiro-cli chat --no-interactive "prompt"
, autenticado por KIRO_API_KEY
. Los docs son explícitos en que "No es posible input del usuario a media sesión": un solo prompt, de principio a fin, sin reanudar.kiro-cli chat --resume
(también --resume-picker
, --resume-id <ID>
, --list-sessions
).Así que para Kiro la sesión siempre encendida es algo que tú construyes:
mantén el proceso vivo en un multiplexor de terminal, y conéctate a él por SSM.
tmux new-session -d -s agent 'kiro-cli chat'
aws ssm start-session --target "$INSTANCE_ID"
sudo -iu appuser tmux attach -t agent
--resume
es la red de seguridad más que el mecanismo: si el proceso se muere, la conversación sigue en disco, indexada por el directorio de trabajo.
Lectura práctica: en un teléfono, Claude Code gana de calle, una pestaña de navegador le gana a un shell de SSM móvil conectándose a tmux. El modelo de Kiro le queda mejor a una laptop o tablet con una terminal de verdad. El resto de este post aplica a los dos, porque las partes difíciles (credenciales, recuperación, monitoreo) son idénticas.
Una unidad de systemd, y el proceso mismo es la sesión. Como la conexión es de salida, no se requiere nada más.
ExecStart=/home/appuser/.local/bin/claude remote-control --name myapp-cloud --continue
Restart=always
RestartSec=30
La salud es lo que sea que diga systemd: systemctl is-active
. Esa es toda la integración.
Kiro necesita que lo durable sea la terminal, no el proceso del agente, porque no hay sesión que re-registrar. Corre tmux bajo systemd y deja que el agente viva adentro:
[Unit]
Description=Kiro agent session (tmux)
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
Type=forking
User=appuser
WorkingDirectory=/home/appuser/myapp
Environment=PATH=/home/appuser/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/tmux new-session -d -s agent 'kiro-cli chat --resume'
ExecStop=/usr/bin/tmux kill-session -t agent
RemainAfterExit=yes
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
--resume
en ExecStart
es deliberado: si la caja reinicia, la sesión nueva de tmux se reconecta a la conversación que ya está en disco para ese directorio en lugar de arrancar en frío.
Conéctate desde donde sea por SSM, todavía sin puertos de entrada:
aws ssm start-session --target "$INSTANCE_ID"
sudo -iu appuser tmux attach -t agent
La salud significa algo distinto aquí, y este es el único lugar donde los dos caminos de verdad divergen. systemctl is-active
sobre una unidad tmux de tipo forking te dice que tmux está vivo, no que el agente adentro lo esté. Pregúntale a tmux directo:
if sudo -u appuser tmux has-session -t agent 2>/dev/null; then
PANES=$(sudo -u appuser tmux list-panes -t agent -F '#{pane_dead}' | grep -c '^0$')
[ "$PANES" -gt 0 ] && heartbeat OK "tmux:$PANES" || fail "tmux session has no live pane"
else
fail "tmux session missing"
fi
#{pane_dead}
es el detalle que vale la pena guardar. Una sesión de tmux cuyo único panel ya salió sigue respondiendo has-session
con éxito, así que el chequeo ingenuo reporta sano una sesión con un agente muerto adentro, la misma clase de falso negativo que el timer inerte de más adelante.
La historia de credenciales de Kiro es más simple en un aspecto: la auth headless es una sola API key en KIRO_API_KEY
, sin flujo de navegador y sin expiración de sesión, que cae directo en el mismo patrón de Secrets Manager. Vale la pena tenerla en la caja aunque manejes de forma interactiva, porque hace útil la caja para one-shots por script:
kiro-cli chat --no-interactive --trust-all-tools "run the test suite and summarise failures"
Sé deliberado con --trust-all-tools
en una máquina sin atender:
auto-aprueba cada tool call. --trust-tools=read,grep
es el default más seguro para cualquier cosa agendada.
Si de plano prefieres no construir nada de esto para Kiro, AWS publica una muestra Kiro IDE Remote de un clic que pone el IDE completo en un escritorio remoto alcanzable desde un navegador, con Kiro CLI y el AWS CLI preinstalados. Más pesada que una instancia chica, y un perfil de costo distinto, pero se salta el armado.
La caja original era una instancia EC2 definida dentro del stack del
backend de producción. El user-data instalaba el toolchain, clonaba el repo, escribía una unidad de systemd, y la dejaba deshabilitada a propósito: arrancar un agente sin autenticar nada más entra en crash-loop.
Un humano luego se metía por SSM una vez, iniciaba sesión de forma
interactiva, y habilitaba el servicio.
// Dentro del stack del backend de producción. Tres errores separados.
userData.addCommands(
"sudo -u appuser bash -lc 'curl -fsSL https://example-agent-installer.sh | bash' || true",
`sudo -u appuser bash -lc 'test -d ~/myapp || git clone https://github.com/acme/myapp.git ~/myapp' || true`,
// ...archivo de unidad escrito aquí, a propósito sin habilitar...
"systemctl daemon-reload"
);
Eso funcionó por meses. Luego se movió el AMI.
La instancia usaba MachineImage.fromSsmParameter(...)
para seguir la imagen actual de Ubuntu 24.04. Cuando ese parámetro resolvió un AMI más nuevo, CloudFormation vio cambiar el ImageId
, lo cual es un reemplazo, no una actualización. Instancia nueva, volumen nuevo, y todo lo del volumen raíz viejo se fue.
El reemplazo arrancó y se veía bien. No lo estaba:
+ sudo -u ubuntu bash -lc 'test -d ~/myapp || git clone https://github.com/acme/myapp.git ~/myapp'
Cloning into '/home/ubuntu/myapp'...
fatal: could not read Username for 'https://github.com': No such device or address
+ true
Un repo privado, clonado sobre https pelón, sin credenciales. || true
se lo tragó, cloud-init reportó un arranque limpio, y la falla era
invisible a menos que alguien leyera el log.
Las consecuencias se encadenaron. Sin repo, el WorkingDirectory
de la unidad no existía, así que el servicio no podía arrancar aunque estuviera habilitado. No estaba habilitado, porque eso es un paso manual. Y el login del agente vivía solo en el volumen que se acababa de destruir:
$ systemctl status agent-remote-control
Loaded: loaded (/etc/systemd/system/agent-remote-control.service; disabled; preset: enabled)
Active: inactive (dead)
Seis días después alguien fue a usarla.
Tres fallas independientes, una causa raíz: cada camino de recuperación requería un humano, y nada decía que se necesitaba un humano.
Si la caja no puede re-autenticarse sola, cada reemplazo es un outage. Dos secretos, sembrados una vez por cuenta en lugar de por instancia:
/myapp/agent-box-github-token -> string de token pelón
/myapp/agent-box-agent-credentials -> blob JSON de credenciales
El script de bootstrap jala los dos, autentica, clona, y habilita el
servicio. Cada paso es idempotente, así que es seguro correrlo en cada arranque y en un timer.
log "1/5 GitHub auth"
if gh auth status >/dev/null 2>&1; then
log " already authenticated"
else
GH_TOKEN_VALUE="$(secret "$GH_SECRET")"
[ -n "$GH_TOKEN_VALUE" ] || fail "could not read $GH_SECRET"
printf '%s' "$GH_TOKEN_VALUE" | gh auth login --with-token || fail "gh auth login failed"
unset GH_TOKEN_VALUE
fi
gh auth setup-git >/dev/null 2>&1 || true
Dos detalles no obvios:
El script no se puede descargar. La jugada obvia es hacerle curl
desde el repo en el user-data. Ese repo es privado, y el token que autorizaría la descarga es lo que el script instala. Circular. En su lugar, incrusta el script en el user-data: léelo en tiempo de synth y mételo en base64.
El alcance de IAM es exactamente dos ARNs. La caja obtiene secretsmanager:GetSecretValue
sobre sus propios secretos de bootstrap y nada más. No tiene por qué leer el password de la base de datos.
role.addToPolicy(new iam.PolicyStatement({
sid: "ReadBootstrapSecrets",
actions: ["secretsmanager:GetSecretValue"],
resources: [
`arn:aws:secretsmanager:${region}:${account}:secret:/myapp/agent-box-github-token-*`,
`arn:aws:secretsmanager:${region}:${account}:secret:/myapp/agent-box-agent-credentials-*`,
],
}));
Restaurar solo el token OAuth producía un servicio que arrancaba y se moría de inmediato:
Error: Unable to determine your organization for Remote Control eligibility. Run `claude auth login` to refresh your account information.
La funcionalidad de remote-control necesita contexto de cuenta, los
identificadores de la cuenta y de la organización, junto con el token.
Incómodamente, en macOS estos viven en dos lugares distintos: el token en el keychain de login, el objeto de cuenta en el propio archivo de config del CLI. Una restauración que lee solo uno de los dos se ve completa y falla en runtime.
Guarda los dos en un secreto y escríbelos a sus dos destinos en la caja:
if sec.get("oauthAccount"):
d["oauthAccount"] = sec["oauthAccount"]
if sec.get("organizationUuid"):
d["organizationUuid"] = sec["organizationUuid"]
Un CLI de agente pregunta si debería confiar en un workspace en el primer uso dentro de un directorio. Un servicio de systemd no tiene TTY y no puede contestar, así que entra en crash-loop en el prompt. Pre-configura la bandera:
e = d.setdefault("projects", {}).setdefault(workdir, {})
e["hasTrustDialogAccepted"] = True
e["hasCompletedProjectOnboarding"] = True
d["hasCompletedOnboarding"] = True
En esta es fácil perder una hora, porque el texto del error es sobre trust y no dice nada de TTYs.
La primera versión del paso de contexto de cuenta hacía pipe de un secreto hacia Python mientras también usaba un heredoc para el script:
secret "$CLAUDE_SECRET" | python3 - "$WORKDIR" <<'TPY'
sec = json.load(sys.stdin)
El heredoc es stdin. El pipe se descarta, y Python lee su propio código fuente como el payload JSON:
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
Escribe el secreto a un archivo mktemp
con umask 077
y pasa la ruta como argumento.
Restart=always
maneja el caso fácil: el proceso crasheó, arráncalo de nuevo.
[Unit]
Description=Agent remote-control session
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
User=appuser
WorkingDirectory=/home/appuser/myapp
Environment=PATH=/home/appuser/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/appuser/.local/bin/claude remote-control --name myapp-cloud --continue
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
StartLimitIntervalSec=0
importa más de lo que parece. Por default systemd se rinde después de una ráfaga de fallas rápidas y estaciona la unidad en failed
permanentemente. En una caja sin atender eso convierte un problema de red pasajero en un outage que dura hasta que alguien se da cuenta, que es justo el modo de falla que estamos diseñando para que no pase.
Pero Restart=always
no puede ayudar con los estados que de verdad mataron esta caja: credenciales faltantes, un clone borrado, una unidad dejada deshabilitada. Esos necesitan algo fuera de la unidad. Como el script de bootstrap es idempotente, el watchdog es nada más el bootstrap en un timer:
[Unit]
Description=Watchdog for the agent session
[Timer]
OnBootSec=60
OnCalendar=*:0/5
AccuracySec=30s
Persistent=true
[Install]
WantedBy=timers.target
OnUnitActiveSec
en una unidad que nunca ha corrido
El primer timer usaba OnUnitActiveSec=5min
. Se instaló limpio, reportó habilitado, y nunca disparó: ese setting es relativo a la última activación de la unidad, y una unidad que nunca ha corrido no tiene siguiente disparo:
NEXT LEFT LAST PASSED UNIT
- - Wed 2026-07-22 16:43:57 UTC 13ms ago agent-bootstrap.timer
NEXT
es -
. Un watchdog que está inerte en silencio es peor que ninguno, porque se lee como cubierto. Usa OnCalendar
de reloj de pared en su lugar.
Todo lo de arriba acorta el outage. Nada de eso acorta los seis días, porque una recuperación de la que nunca te enteras es indistinguible de ninguna recuperación.
El watchdog ya corre cada cinco minutos y ya sabe si el servicio está sano.
Haz que lo diga, en voz alta, a algún lugar fuera de la caja:
heartbeat() {
local status="$1" detail="${2:-}"
local stream ts
stream="$(cat /var/lib/cloud/data/instance-id 2>/dev/null || hostname)"
ts="$(date +%s)000"
aws logs create-log-stream --region "$REGION" --log-group-name "$LOG_GROUP" \
--log-stream-name "$stream" >/dev/null 2>&1 || true
aws logs put-log-events --region "$REGION" --log-group-name "$LOG_GROUP" \
--log-stream-name "$stream" \
--log-events "timestamp=$ts,message=AGENT_BOX_HEARTBEAT $status $detail" \
>/dev/null 2>&1 || log "WARN: heartbeat not delivered"
}
fail() { log "ERROR: $*"; heartbeat DOWN "$*"; exit 1; }
Un metric filter cuenta las líneas sanas, y la alarma dispara ante su
ausencia:
new logs.MetricFilter(this, "HeartbeatMetricFilter", {
logGroup,
metricNamespace: "myapp/AgentBox",
metricName: "RemoteControlHeartbeat",
filterPattern: logs.FilterPattern.literal('"AGENT_BOX_HEARTBEAT OK"'),
metricValue: "1",
defaultValue: 0,
});
new cw.Alarm(this, "RemoteControlDownAlarm", {
alarmName: "myapp-agent-box-down",
metric: new cw.Metric({
namespace: "myapp/AgentBox",
metricName: "RemoteControlHeartbeat",
period: cdk.Duration.minutes(15),
statistic: "Sum",
}),
threshold: 1,
comparisonOperator: cw.ComparisonOperator.LESS_THAN_THRESHOLD,
evaluationPeriods: 1,
treatMissingData: cw.TreatMissingData.BREACHING,
}).addAlarmAction(new cwactions.SnsAction(alarmTopic));
** treatMissingData: BREACHING es todo el diseño.** El instinto es alarmar sobre una línea de error, pero cada modo de falla en este postmortem produjo
NOT_BREACHING
.Dos detalles de apoyo. El heartbeat dispara también en el camino de falla, así que una corrida rota es tan visible como una sana: la alarma se indexa en la ausencia de OK
, no en la presencia de DOWN
. Y activating
cuenta como sano, ya que es el estado normal por unos segundos después de cualquier reinicio:
STATE="$(systemctl is-active agent-remote-control 2>/dev/null)"
case "$STATE" in
active|activating) heartbeat OK "$STATE" ;;
*) fail "service is $STATE" ;;
esac
Cambia por el chequeo de tmux has-session
#{pane_dead}
de antes para una caja de Kiro. Todo lo de aguas abajo (log group, metric filter, alarma, topic de SNS) es idéntico, porque la línea de heartbeat es el único contrato entre la caja y la alarma.
La caja original vivía en el stack del backend de producción. Eso es lo que la mató: un parámetro de AMI dentro de ese stack reemplazó la instancia. Cada deploy de producción también la detenía/arrancaba.
Una comodidad de desarrollo no tiene por qué compartir ciclo de vida con producción. Su propio stack, con un lookup de VPC en lugar de un export entre stacks, un export recrearía el acoplamiento que la separación existe para quitar:
export class AgentBoxStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: Props) {
super(scope, id, props);
const vpc = ec2.Vpc.fromLookup(this, "Vpc", { vpcName: "myapp-vpc" });
const alarmTopic = new sns.Topic(this, "AgentBoxAlerts", {
topicName: "myapp-agent-box-alerts",
});
alarmTopic.addSubscription(new snssub.EmailSubscription("ops@example.com"));
new AgentBox(this, "AgentBox", {
appName: "myapp",
vpc,
githubRepo: "acme/myapp",
alarmTopic,
});
}
}
Su propio topic de SNS, también. Importar el topic de producción metería el stack de vuelta justo en el grafo de dependencias.
El movimiento destruye y recrea la instancia, y el orden no es opcional si algún recurso tiene un nombre físico fijo. El rol de IAM aquí lo tiene:
npx cdk deploy MyApp-Backend # borra la caja vieja y su rol de nombre fijo
npx cdk deploy MyApp-AgentBox # los crea de nuevo
Al revés, el segundo deploy falla por un nombre de rol duplicado.
La separación del stack destruyó la instancia y construyó una nueva desde cero, lo que reproduce la falla original exactamente. Esta vez nadie la tocó:
MyApp-AgentBox | 12/13 | CREATE_COMPLETE | AWS::EC2::Instance | AgentBox/Instance
MyApp-AgentBox | 13/13 | CREATE_COMPLETE | AWS::CloudFormation::Stack | MyApp-AgentBox
✅ MyApp-AgentBox
✨ Deployment time: 177.81s
Como dos minutos después, sin atender:
[agent-box-bootstrap] 1/5 GitHub auth
[agent-box-bootstrap] 2/5 Repo clone
[agent-box-bootstrap] 3/5 Agent credentials
[agent-box-bootstrap] 4/5 Account context + workspace trust
[agent-box-bootstrap] 5/5 Enabling the always-on service
[agent-box-bootstrap] done (active)
systemd[1]: Finished agent-bootstrap.service.
systemd[1]: agent-bootstrap.service: Consumed 2.954s CPU time, 52.5M memory peak
Y la alarma hizo su trabajo a lo largo de toda la ventana. Durante el hueco del arranque:
Threshold Crossed: no datapoints were received for 1 period and 1 missing
datapoint was treated as [Breaching]. ALARM
luego, por su cuenta, en cuanto aterrizó el primer heartbeat:
Threshold Crossed: 1 datapoint [1.0 (22/07/26 20:40:00)] was not less than
the threshold (1.0). OK
Una alarma que dispara ante un outage real y se limpia sola sin ayuda es todo el entregable. La recuperación está padre; la alarma es lo que convierte seis días en quince minutos.
|| true
es una decisión de nunca enterarte.ImageId
lo destruye. La misma superficie de diff, un radio de impacto salvajemente distinto: lee el plan antes de desplegar.NEXT
del timer. El monitoreo inerte es peor que ninguno, porque se cuenta como cobertura.