cd /news/developer-tools/sesiones-remotas-siempre-disponibles… · home topics developer-tools article
[ARTICLE · art-73639] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Sesiones remotas siempre disponibles para tus agentes de codigo

A developer shows how to run persistent remote coding agent sessions on a single small EC2 instance, surviving laptop closures and infrastructure changes. Claude Code offers native remote sessions via outbound-only connections, while Kiro CLI requires building a custom setup with tmux and SSM. The post details systemd units, credential management, and monitoring for both approaches.

read15 min views1 publishedJul 25, 2026

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

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/sesiones-remotas-sie…] indexed:0 read:15min 2026-07-25 ·