I built a web terminal that can operate Kiro CLI from a browser using the Shell API of Amazon Bedrock AgentCore

I built a web terminal that can operate Kiro CLI from a browser using the Shell API of Amazon Bedrock AgentCore

I built a Web Terminal PoC using xterm.js + Node.js with the Shell API of Amazon Bedrock AgentCore Runtime. I will summarize the SigV4-signed WebSocket connection and the binary frame format confirmed during this verification.
2026.06.07

This page has been translated by machine translation. View original

Introduction

On 2026/06/05, a Shell API (InvokeAgentRuntimeCommandShell) was added to Amazon Bedrock AgentCore Runtime. This allows remote operation of the interactive shell on microVMs in AgentCore via WebSocket.

https://aws.amazon.com/about-aws/whats-new/2026/06/amazon-bedrock-agentcore-runtime/

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-get-started-command-shell.html

Kiro has three interfaces: IDE, CLI, and Web. This PoC operates Kiro CLI via a web terminal using AgentCore Runtime's Shell API. Please note that this differs from the existing Kiro Web.

Comparison Item Kiro Web (GitHub Integration) This PoC (Web Terminal via Shell API)
Authentication GitHub OAuth None in this PoC. For public configurations, design separately within your organization (Cognito, etc.)
Execution Environment Kiro-managed cloud environment AgentCore Runtime microVM (custom container)
Customization Kiro standard configuration Container image can be freely customized
Workflow Integration GitHub PR-based Can be designed to fit your use case
Intended Use Remote AI coding Verification of dedicated AI agent distribution using Shell API

This article constructs a "minimum PoC without authentication running on localhost" for operational verification. This PoC is for local verification only. Do not publish to internal or external networks without implementing authentication, authorization, Origin checks, etc.

As a preceding article, the basic setup of AgentCore Runtime and verification of Kiro CLI / Codex CLI have been conducted below.

https://dev.classmethod.jp/articles/bedrock-agentcore-runtime-kiro-cli/

https://dev.classmethod.jp/articles/bedrock-agentcore-runtime-codex-cli-claude-code/

Verification Content

Architecture

The configuration consists of the following 3 layers.

Browser (xterm.js) ←→ Node.js relay server (Docker) ←→ AgentCore Shell API (microVM)

The Node.js server handles SigV4 signing and establishes a WebSocket connection to AgentCore. The design does not pass AWS credentials to the browser (the purpose is to prevent credential exposure; separate authorization for browser connections is required. See notes for details). The relay server runs as a Docker container, eliminating the need to install Node.js directly on the host environment. Inside the container, it listens on 0.0.0.0, but is only exposed to the loopback address on the host side via -p 127.0.0.1:3000:3000.

Shell API Frame Format (as confirmed in this verification)

In this verification, we confirmed that Shell API WebSocket messages can be communicated by treating "the first 1 byte as the channel number and the remainder as the payload." The following channel numbers are values observed in this verification (see notes below).

Channel Direction Purpose
ch0 (stdin) Client → Agent Key input / command transmission
ch1 (stdout) Agent → Client Standard output
ch2 (stderr) Agent → Client Standard error output
ch3 (confirmation) Agent → Client Shell establishment notification (JSON containing metadata.shellId observed in this verification)
ch4 (resize) Client → Agent Terminal size change (JSON: {width: columns, height: rows})
ch5 (heartbeat) Client → Agent Keep-alive (sent at 30-second intervals in verification)
ch255 (close) Client → Agent Shell termination (separate from WebSocket Close frame)

Connection endpoints:

# New connection
wss://bedrock-agentcore.<region>.amazonaws.com/runtimes/<encoded-runtime-arn>/ws/shells?qualifier=DEFAULT

# On reconnection (specify shellId to attach to existing Shell)
wss://bedrock-agentcore.<region>.amazonaws.com/runtimes/<encoded-runtime-arn>/ws/shells?qualifier=DEFAULT&shellId=<shellId>

Key Limitations

The following are based on the limit values documented in the official documentation, with the close code behavior confirmed in this verification noted alongside. The correspondence with close codes and reconnection availability include verification results, not official guarantees.

Item Value Notes
Frame size 64KB In this PoC, for safety, split so that including the 1-byte channel number it stays within 64KB. Close code 1009 on excess
Frame rate 250 frames/sec Close code 1008 on excess
Connection TTL 1 hour Limit documented in official documentation. Close code and reconnection behavior at TTL expiration not confirmed in this verification
Reconnection buffer 256KB Value documented in official documentation. Reconnect to existing Shell with same X-Amzn-Bedrock-AgentCore-Runtime-Session-Id and shellId query parameter. Relationship with replayed output (replay buffer) not confirmed in this verification
Concurrent session limit 10
Close Code List
Code Meaning Reconnect
1000 Normal closure No
1001 Going away (server shutdown) Yes
1003 Text frame sent (binary only violation) No
1006 Abnormal closure (network disconnection, etc. Not a code sent as a Close frame, but a state observed on the client side) Yes
1008 Policy violation (TTL / rate limit / buffer overflow) Varies by condition. Reconnection at TTL expiration not verified in this verification
1009 Frame too big (exceeds 64KB) No
1011 Server error Yes
4000 Another client connected with the same session_id + shellId, replacing the existing connection No

Core Implementation

SigV4-Signed WebSocket Connection

Using @smithy/signature-v4, sign the connection URL and headers. In this PoC, the X-Amzn-Bedrock-AgentCore-Runtime-Session-Id header is attached per connection and included in the SigV4 signing target. The same session_id is used on reconnection.

async function signWebSocketRequest(region, url, sessionId) {
  const query = Object.fromEntries(url.searchParams.entries());
  const headers = { host: url.hostname };
  if (sessionId) {
    headers["x-amzn-bedrock-agentcore-runtime-session-id"] = sessionId;
  }
  const request = new HttpRequest({
    method: "GET",
    protocol: "https:",
    hostname: url.hostname,
    path: url.pathname,
    query,
    headers,
  });
  const signer = new SignatureV4({
    service: "bedrock-agentcore",
    region,
    credentials: fromNodeProviderChain(),
    sha256: Sha256,
  });
  return (await signer.sign(request)).headers;
}

Binary Frame Relay and 64KB Chunking

Input exceeding 64KB is split into frames before sending. In this verification environment, xterm.js's onData was subdividing paste input finely, so the browser-side 64KB splitting rarely fired. However, the split processing is included in the implementation as a failsafe.

const MAX_FRAME_SIZE = 64 * 1024;
const MAX_PAYLOAD_SIZE = MAX_FRAME_SIZE - 1;

function chunkPayload(channel, data) {
  const frames = [];
  const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
  for (let offset = 0; offset < buf.length; offset += MAX_PAYLOAD_SIZE) {
    const chunk = buf.slice(offset, offset + MAX_PAYLOAD_SIZE);
    frames.push(Buffer.concat([Buffer.from([channel]), chunk]));
  }
  return frames;
}

Reconnection Design

When implementing without sending ch255 on browser disconnection, we confirmed in this verification that the Shell session on the AgentCore side was maintained and re-attachment was possible. In this verification, we confirmed the behavior that connecting again with the same session_id (X-Amzn-Bedrock-AgentCore-Runtime-Session-Id) and shellId re-attaches to the existing Shell. At that time, output within the range remaining in the replay buffer was resent. In this verification, it was primarily observed as STDOUT frames (ch1).

clientWs.on("close", () => {
  if (agentWs && agentWs.readyState === WebSocket.OPEN) {
    // Do NOT send ch255 — keep PTY alive for reconnection
    agentWs.close();
  }
});

For disconnections between the browser and the Node.js relay server, automatic reconnection is performed with exponential backoff (1s, 2s, 4s, 8s, 8s, maximum 5 times, upper limit 8s). Reconnection behavior per AgentCore side close code was only partially confirmed in this verification.

Operational Verification

Startup

cd poc
docker build -t agentcore-shell .
docker run -d --name agentcore-shell \
  --restart unless-stopped \
  -p 127.0.0.1:3000:3000 \
  -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN \
  -e AWS_REGION=us-west-2 \
  -e RUNTIME_ARN=arn:aws:bedrock-agentcore:us-west-2:<account-id>:runtime/<runtime-id> \
  -e SSM_PARAM_NAME=/your/api-key-param \
  agentcore-shell
# → Open http://localhost:3000 in browser

Shell Establishment + API Key Retrieval via SSM

After connecting to the Shell API, when the shellId is received in the confirmation frame (ch3), the Node.js server sends initialization commands to the Shell. Inside the microVM, KIRO_API_KEY is retrieved from SSM Parameter Store with the permissions of the Execution Role and exported as an environment variable (see notes for IAM permission details).

[agent] Shell ready: <shellId>
[agent] Sent KIRO_API_KEY init command
[KIRO ready]
root@localhost:/#

Headless Mode

root@localhost:/# kiro-cli chat --no-interactive "What is 2+2? One word."

> Four.

 ▸ Credits: 0.02 • Time: 1s

Interactive Mode

Kiro CLI Interactive Mode

We confirmed that the Welcome message is displayed and responses are returned even in interactive mode.

Additional Verification Results

Verification Item Result
Reconnection (reconnect with session_id + shellId) 5/5 PASS
Replay buffer (previous output resent on reconnection) Confirmed
Environment variable persistence (KIRO_API_KEY valid when re-attaching to existing Shell with same session_id + shellId) Confirmed
Terminal resize (browser width tracking via ch4) Confirmed
Heartbeat (ch5 sent at 30-second intervals) Connection maintenance confirmed within verification time (comparison without sending not performed)
Large output 120KB No disconnection
Large input 600KB (file write) Successful with this procedure and transmission speed
Close code 4000 (replacement by another client connection) Operation confirmed
1-hour TTL (close code 1008) Not verified (reconnection behavior at TTL expiration unconfirmed)

Notes

This PoC assumes no authentication and localhost. Do not publish as-is.

Regarding security, the following points require attention.

  • The Node.js server binds to 0.0.0.0 inside the container. When publishing to the host, explicitly specify the loopback address like docker run -p 127.0.0.1:3000:3000 and do not expose it to external networks. When starting directly without using a container, it is recommended to change this to 127.0.0.1. The web terminal becomes an entry point where those who connect can execute commands on the execution environment
  • WebSocket Origin checks are required even on localhost. Cross-Site WebSocket Hijacking, where a malicious website attempts a connection to ws://localhost:3000, can succeed. Validate the Origin header on the server side and limit allowed Origins (omitted in this PoC)
  • With the method of sending export KIRO_API_KEY=$(aws ssm ...) to stdin, depending on the PTY echo settings, the command string may be displayed in the terminal. Also be aware of residual data in ~/.bash_history, inclusion in reconnect replay, and appearance in browser DevTools or screenshots. For public configurations, consider countermeasures such as injection via file or set +o history

Regarding IAM permission separation:

  • The IAM principal by which the Node.js relay server connects to the Shell API with SigV4 signing, and the Execution Role that calls AWS APIs inside the microVM, are separate principals
  • Shell API connection permissions are granted to the former, and ssm:GetParameter permissions are granted to the latter (Execution Role)
  • SSM Parameter Store is placed in us-east-1 due to verification environment constraints. Explicit specification of --region us-east-1 is required. For production configurations, placement in the same region as AgentCore is recommended

Regarding ch255 and Shell lifecycle:

  • When intending to terminate the Shell, the implementation in this PoC's frame format sends ch255
  • The behavior where the Shell is maintained upon disconnection without sending ch255 is a verification result. It should not be relied upon as a permanent specification. For public configurations, cleanup design for orphan shells is also a consideration

Regarding single-client constraints:

  • The relay server in this PoC holds session information in global variables on memory. It is a minimal configuration that does not consider simultaneous connections or individual management from multiple browsers or multiple tabs.

Summary

We built a Web Terminal PoC using AgentCore Runtime's Shell API and xterm.js, and verified SigV4-signed WebSocket connections, binary frame relay, 64KB chunking, and Shell session persistence via reconnection. Note that some behaviors, such as the behavior upon reaching the 1-hour TTL, remain unverified.

This PoC is a minimal configuration verification. If you plan to expose this within your organization, please separately design authentication/authorization, session_id / shellId separation, Origin checks, and Shell lifecycle management.

https://dev.classmethod.jp/articles/cognito-cloudfront-vpc-origin-agentcore-websocket/

Full PoC (server.mjs)
import { createServer } from "http";
import crypto from "crypto";
import { WebSocketServer, WebSocket } from "ws";
import { SignatureV4 } from "@smithy/signature-v4";
import { Sha256 } from "@aws-crypto/sha256-js";
import { HttpRequest } from "@smithy/protocol-http";
import credentialProviders from "@aws-sdk/credential-providers";
const { fromNodeProviderChain } = credentialProviders;

const REGION = process.env.AWS_REGION || "us-west-2";
const RUNTIME_ARN = process.env.RUNTIME_ARN; // arn:aws:bedrock-agentcore:<region>:<account-id>:runtime/<runtime-id>
const PORT = parseInt(process.env.PORT || "3000");
const SSM_PARAM_NAME = process.env.SSM_PARAM_NAME || "/your/api-key-param";
const SSM_REGION = process.env.SSM_REGION || "us-east-1";

// Frame size limit from official docs; close-code behavior was observed in this PoC
const MAX_FRAME_SIZE = 64 * 1024; // 64KB — close code 1009 if exceeded
const MAX_PAYLOAD_SIZE = MAX_FRAME_SIZE - 1; // channel byte takes 1 byte
const SHELL_ID_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/;

// Close code descriptions (for logging and client feedback)
const CLOSE_CODES = {
  1000: "Normal closure",
  1001: "Going away (server shutdown) — reconnectable",
  1003: "Unsupported data (text frames sent — binary only)",
  1006: "Abnormal closure (network death) — reconnectable",
  1008: "Policy violation (TTL expired / rate limit / write buffer overflow)",
  1009: "Message too big (frame > 64KB)",
  1011: "Server error",
  4000: "Replaced (another client connected with same session_id + shell_id)",
};

// Codes that should NOT auto-reconnect
const NO_RECONNECT_CODES = new Set([1003, 4000]);

function isValidShellId(id) {
  return SHELL_ID_PATTERN.test(id);
}

// Split large payloads into chunks under 64KB to avoid close code 1009
function chunkPayload(channel, data) {
  const frames = [];
  const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
  for (let offset = 0; offset < buf.length; offset += MAX_PAYLOAD_SIZE) {
    const chunk = buf.slice(offset, offset + MAX_PAYLOAD_SIZE);
    frames.push(Buffer.concat([Buffer.from([channel]), chunk]));
  }
  return frames;
}

function getDataPlaneHost(region) {
  return `bedrock-agentcore.${region}.amazonaws.com`;
}

function buildShellUrl(region, runtimeArn, shellId) {
  const host = getDataPlaneHost(region);
  const encoded = encodeURIComponent(runtimeArn);
  const url = new URL(`wss://${host}/runtimes/${encoded}/ws/shells`);
  url.searchParams.set("qualifier", "DEFAULT");
  if (shellId) url.searchParams.set("shellId", shellId);
  return url;
}

async function signWebSocketRequest(region, url, sessionId) {
  const query = Object.fromEntries(url.searchParams.entries());
  const headers = { host: url.hostname };
  if (sessionId) {
    headers["x-amzn-bedrock-agentcore-runtime-session-id"] = sessionId;
  }
  const request = new HttpRequest({
    method: "GET",
    protocol: "https:",
    hostname: url.hostname,
    path: url.pathname,
    query,
    headers,
  });
  const signer = new SignatureV4({
    service: "bedrock-agentcore",
    region,
    credentials: fromNodeProviderChain(),
    sha256: Sha256,
  });
  const signed = await signer.sign(request);
  return signed.headers;
}

// HTML with xterm.js
const HTML = `<!DOCTYPE html>
<html>
<head>
  <title>AgentCore Shell</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
  <style>body{margin:0;background:#1e1e1e;display:flex;flex-direction:column;height:100vh}
  #terminal{flex:1}#status{color:#aaa;font:12px monospace;padding:4px 8px}</style>
</head>
<body>
  <div id="status">Connecting...</div>
  <div id="terminal"></div>
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
  <script>
    const term = new Terminal({cursorBlink:true, fontSize:14});
    const fit = new FitAddon.FitAddon();
    term.loadAddon(fit);
    term.open(document.getElementById('terminal'));
    fit.fit();
    window.addEventListener('resize', ()=>fit.fit());

    const status = document.getElementById('status');
    let ws = null;
    let retryCount = 0;
    const MAX_RETRIES = 5;

    function connect() {
      ws = new WebSocket('ws://'+location.host+'/ws');
      ws.binaryType = 'arraybuffer';

      ws.onopen = () => {
        retryCount = 0;
        status.textContent = 'Connected - waiting for shell...';
        setTimeout(() => {
          const json = JSON.stringify({width:term.cols, height:term.rows});
          const enc = new TextEncoder().encode(json);
          const frame = new Uint8Array(1+enc.length);
          frame[0]=4; frame.set(enc,1);
          ws.send(frame);
        }, 500);
      };

      ws.onmessage = (e) => {
        const buf = new Uint8Array(e.data);
        const ch = buf[0], payload = buf.slice(1);
        if (ch===1||ch===2) { term.write(payload); }
        else if (ch===3) {
          try {
            const msg = JSON.parse(new TextDecoder().decode(payload));
            if (msg.metadata?.shellId) status.textContent = 'Shell ready: ' + msg.metadata.shellId;
          } catch{}
        }
      };

      ws.onclose = (e) => {
        const noReconnect = [1000, 1003, 1009, 4000];
        if (noReconnect.includes(e.code)) {
          const msgs = {1000:'Session closed normally', 1003:'Protocol error: binary frames only', 1009:'Frame too big', 4000:'Session replaced by another client'};
          status.textContent = msgs[e.code] || 'Disconnected (code '+e.code+')';
          term.write('\\r\\n['+status.textContent+']\\r\\n');
          return;
        }
        if (retryCount < MAX_RETRIES) {
          const delay = Math.min(1000 * Math.pow(2, retryCount), 8000);
          retryCount++;
          status.textContent = 'Reconnecting (' + retryCount + '/' + MAX_RETRIES + ')...';
          setTimeout(connect, delay);
        } else {
          status.textContent = 'Disconnected (max retries reached)';
          term.write('\\r\\n[Connection lost]\\r\\n');
        }
      };

      ws.onerror = () => {};
    }

    term.onData((data) => {
      if (ws && ws.readyState===1) {
        const enc = new TextEncoder().encode(data);
        const MAX_CHUNK = 64 * 1024 - 1; // Payload limit after subtracting 1B for channel byte
        for (let i = 0; i < enc.length; i += MAX_CHUNK) {
          const chunk = enc.slice(i, i + MAX_CHUNK);
          const frame = new Uint8Array(1+chunk.length);
          frame[0]=0; frame.set(chunk,1);
          ws.send(frame);
        }
      }
    });

    term.onResize(({cols,rows}) => {
      if (ws && ws.readyState===1) {
        const json = JSON.stringify({width:cols, height:rows});
        const enc = new TextEncoder().encode(json);
        const frame = new Uint8Array(1+enc.length);
        frame[0]=4; frame.set(enc,1);
        ws.send(frame);
      }
    });

    connect();
  </script>
</body>
</html>`;

// HTTP server
const httpServer = createServer((req, res) => {
  if (req.url === "/" || req.url === "/index.html") {
    res.writeHead(200, { "Content-Type": "text/html" });
    res.end(HTML);
  } else {
    res.writeHead(404);
    res.end("Not found");
  }
});

// WebSocket server for browser clients
const wss = new WebSocketServer({ noServer: true });

// Session state (single-client PoC)
let savedShellId = null;
let savedSessionId = null;

httpServer.on("upgrade", (req, socket, head) => {
  if (req.url === "/ws") {
    wss.handleUpgrade(req, socket, head, (clientWs) => {
      wss.emit("connection", clientWs, req);
    });
  } else {
    socket.destroy();
  }
});

wss.on("connection", async (clientWs) => {
  console.log("[client] Browser connected");
  let agentWs = null;

  try {
    const shellId = savedShellId || undefined;
    if (shellId && !isValidShellId(shellId)) {
      console.error(`[agent] Invalid shellId: ${shellId} — resetting`);
      savedShellId = null;
    }
    const validShellId = savedShellId || undefined;
    if (!savedSessionId) {
      savedSessionId = crypto.randomUUID();
    }
    const url = buildShellUrl(REGION, RUNTIME_ARN, validShellId);
    const headers = await signWebSocketRequest(REGION, url, savedSessionId);
    console.log(`[agent] Connecting to ${url.hostname}... (shellId=${validShellId || "new"}, sessionId=${savedSessionId.slice(0, 8)}...)`);

    agentWs = new WebSocket(url.toString(), { headers });

    agentWs.on("open", () => console.log("[agent] WebSocket open"));
    agentWs.on("error", (e) => {
      console.error("[agent] Error:", e.message);
      clientWs.close(1011, "Agent connection error");
    });
    agentWs.on("close", (code) => {
      const desc = CLOSE_CODES[code] || "Unknown";
      console.log(`[agent] Closed (code ${code}: ${desc})`);
      if (clientWs.readyState === WebSocket.OPEN) {
        // Convert 1006 and reserved codes below 1000 to 1011 since they cannot be set in a Close frame
        const clientCode = (code === 1006 || code < 1000) ? 1011 : code;
        clientWs.close(clientCode, desc);
      }
    });

    // Agent → Client (binary relay with backpressure)
    agentWs.on("message", (data, isBinary) => {
      if (clientWs.readyState === WebSocket.OPEN) {
        if (clientWs.bufferedAmount > 1024 * 1024) {
          agentWs.pause();
          const check = () => {
            if (clientWs.bufferedAmount < 512 * 1024) {
              agentWs.resume();
            } else {
              setTimeout(check, 50);
            }
          };
          setTimeout(check, 50);
        }
        clientWs.send(data, { binary: true });
      }
      const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
      if (buf[0] === 3) {
        try {
          const msg = JSON.parse(buf.slice(1).toString());
          if (msg.metadata?.shellId) {
            const isReconnect = savedShellId === msg.metadata.shellId;
            savedShellId = msg.metadata.shellId;
            console.log(`[agent] Shell ready: ${savedShellId} (reconnect=${isReconnect})`);
            if (!isReconnect) {
              // Safety margin for PTY readiness (empirical practice from testing, not a spec requirement)
              setTimeout(() => {
                const initCmd = `export KIRO_API_KEY=$(aws ssm get-parameter --name "${SSM_PARAM_NAME}" --with-decryption --query 'Parameter.Value' --output text --region ${SSM_REGION}) && [ -n "$KIRO_API_KEY" ] && echo "[KIRO ready]" || echo "[SSM fetch failed]"\n`;
                const frame = Buffer.concat([Buffer.from([0]), Buffer.from(initCmd)]);
                if (agentWs.readyState === WebSocket.OPEN) {
                  agentWs.send(frame);
                  console.log("[agent] Sent KIRO_API_KEY init command");
                }
              }, 500);
            }
          }
        } catch {}
      }
    });

    // Client → Agent (binary relay with frame size guard)
    clientWs.on("message", (data, isBinary) => {
      if (!agentWs || agentWs.readyState !== WebSocket.OPEN) return;
      const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
      if (buf.length <= MAX_FRAME_SIZE) {
        agentWs.send(buf);
      } else {
        const channel = buf[0];
        const payload = buf.slice(1);
        const frames = chunkPayload(channel, payload);
        for (const frame of frames) {
          if (agentWs.readyState === WebSocket.OPEN) agentWs.send(frame);
        }
        console.log(`[relay] Chunked ${buf.length} bytes into ${frames.length} frames`);
      }
    });

    clientWs.on("close", () => {
      console.log("[client] Browser disconnected");
      if (agentWs && agentWs.readyState === WebSocket.OPEN) {
        // Do NOT send ch255 CLOSE frame — keep PTY alive for reconnection
        agentWs.close();
      }
    });

    // Heartbeat every 30s
    const heartbeat = setInterval(() => {
      if (agentWs && agentWs.readyState === WebSocket.OPEN) {
        agentWs.send(Buffer.from([5]));
      }
    }, 30000);

    agentWs.on("close", () => clearInterval(heartbeat));
    clientWs.on("close", () => clearInterval(heartbeat));

  } catch (e) {
    console.error("[error]", e.message);
    clientWs.close(1011, e.message);
  }
});

httpServer.listen(PORT, "0.0.0.0", () => {
  console.log(`\n  AgentCore Web Shell PoC`);
  console.log(`  http://localhost:${PORT}`);
  console.log(`  Runtime: ${RUNTIME_ARN}`);
  console.log(`  Region:  ${REGION}`);
  console.log(`  Bind:    0.0.0.0 (container mode)\n`);
});

Dependency packages (package.json):

{
  "name": "agentcore-webshell-poc",
  "type": "module",
  "scripts": { "start": "node server.mjs" },
  "dependencies": {
    "@aws-crypto/sha256-js": "^5.2.0",
    "@aws-sdk/credential-providers": "^3.850.0",
    "@smithy/protocol-http": "^5.1.0",
    "@smithy/signature-v4": "^5.0.2",
    "ws": "^8.18.2"
  }
}

Dockerfile:

FROM node:22-slim
WORKDIR /app
COPY package.json .
RUN npm install
COPY server.mjs .
EXPOSE 3000
CMD ["node", "server.mjs"]

How to start:

cd poc
docker build -t agentcore-shell .
docker run -d --name agentcore-shell \
  --restart unless-stopped \
  -p 127.0.0.1:3000:3000 \
  -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN \
  -e AWS_REGION=us-west-2 \
  -e RUNTIME_ARN=arn:aws:bedrock-agentcore:us-west-2:<account-id>:runtime/<runtime-id> \
  -e SSM_PARAM_NAME=/your/api-key-param \
  agentcore-shell

Share this article

AWSのお困り事はクラスメソッドへ