Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 828eff1..f04f140 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -190,6 +190,8 @@ set(NETHACK_MCP_SOURCES
src/game_http_server.cpp)
set(STATIC_FILES
+ "${CMAKE_CURRENT_SOURCE_DIR}/web/AGENT.md"
+ "${CMAKE_CURRENT_SOURCE_DIR}/web/copy_prompt.js"
"${CMAKE_CURRENT_SOURCE_DIR}/web/home.html"
"${CMAKE_CURRENT_SOURCE_DIR}/web/home.css"
"${CMAKE_CURRENT_SOURCE_DIR}/web/local_time.js"
diff --git a/README.md b/README.md
index 1cf26bf..58dd645 100644
--- a/README.md
+++ b/README.md
@@ -30,8 +30,10 @@ compiled into the executable. Asset changes regenerate that translation unit.
```
The MCP endpoint is `http://127.0.0.1:8765/mcp`; the read-only landing page is
-at `http://127.0.0.1:8765/`. Game records are stored by default at
-`~/.local/share/nethack-mcp/games.sqlite3`, outside the temporary runtime
+at `http://127.0.0.1:8765/`. The landing page includes a short prompt for
+agents, and `/AGENT.md` explains how to connect and play. Game records are
+stored by default at `~/.local/share/nethack-mcp/games.sqlite3`, outside the
+temporary runtime
directory. Stop the server with Ctrl-C. A server restart ends all active
games and records them as interrupted.
diff --git a/cmake/embed_assets.cmake b/cmake/embed_assets.cmake
index 2e560ba..419bf84 100644
--- a/cmake/embed_assets.cmake
+++ b/cmake/embed_assets.cmake
@@ -21,6 +21,8 @@ foreach(ASSET IN LISTS STATIC_FILES)
get_filename_component(ASSET_EXTENSION "${ASSET}" LAST_EXT)
if(ASSET_EXTENSION STREQUAL ".html")
set(ASSET_TYPE "text/html; charset=utf-8")
+ elseif(ASSET_EXTENSION STREQUAL ".md")
+ set(ASSET_TYPE "text/markdown; charset=utf-8")
elseif(ASSET_EXTENSION STREQUAL ".css")
set(ASSET_TYPE "text/css; charset=utf-8")
elseif(ASSET_EXTENSION STREQUAL ".js")
diff --git a/src/game_http_server.cpp b/src/game_http_server.cpp
index 42be3ba..4e961c6 100644
--- a/src/game_http_server.cpp
+++ b/src/game_http_server.cpp
@@ -273,6 +273,14 @@ void GameHttpServer::setup()
server.Get("/", [this](const Request& request, Response& response) {
servePage(request, response);
});
+ server.Get("/AGENT.md", [this](const Request& request,
+ Response& response) {
+ serveStatic("/AGENT.md", request, response);
+ });
+ server.Get("/copy-prompt.js", [this](const Request& request,
+ Response& response) {
+ serveStatic("/copy_prompt.js", request, response);
+ });
server.Get(R"(/g/[0-9a-f-]{36})",
[this](const Request& request, Response& response) {
serveGamePage(request, response);
@@ -488,6 +496,14 @@ void GameHttpServer::serveStatic(std::string_view path,
return;
}
response.set_header("Cache-Control", "public, max-age=3600");
+ if(path == "/AGENT.md")
+ {
+ std::string guide(asset->content);
+ replaceTemplateValue(guide, "{{MCP_URL}}",
+ manager_.publicBaseUrl() + "mcp");
+ response.set_content(guide, std::string(asset->content_type));
+ return;
+ }
response.set_content(asset->content.data(), asset->content.size(),
std::string(asset->content_type));
}
@@ -755,6 +771,11 @@ std::string GameHttpServer::recentPage()
std::string html(asset->content);
replaceTemplateValue(html, "{{MCP_URL}}",
htmlEscape(manager_.publicBaseUrl() + "mcp"));
+ replaceTemplateValue(
+ html, "{{AGENT_PROMPT}}",
+ htmlEscape("Read and follow " + manager_.publicBaseUrl()
+ + "AGENT.md. Connect to its MCP endpoint and play NetHack "
+ "autonomously. Share the spectator link with me."));
replaceTemplateValue(html, "{{RECENT_GAMES}}", rows);
return html;
}
diff --git a/web/AGENT.md b/web/AGENT.md
new file mode 100644
index 0000000..e1db85e
--- /dev/null
+++ b/web/AGENT.md
@@ -0,0 +1,62 @@
+# Play NetHack with this server
+
+You are playing a real NetHack game through the tools on this server. Play
+autonomously until the game ends, and tell the user what happened.
+
+## Connect
+
+The remote MCP endpoint is `{{MCP_URL}}`. Add it as a Streamable HTTP MCP
+server in your agent environment. The server exposes `new_game`, `observe`,
+`press`, `select_menu`, `respond`, and `quit_game`.
+
+Reading this page does not connect the MCP tools. If you cannot call them,
+tell the user that the remote MCP server needs to be connected. The
+spectator pages cannot control a game.
+
+## Start a game
+
+Call `new_game` once with `model_slug` set to a short, printable ASCII
+name for your model or agent. You may also provide a character `name`.
+The result contains `game_id`, `control_token`, `viewer_url`, and the
+first `state`. Keep `game_id` and `control_token` for subsequent tool
+calls. Share the `viewer_url` with the user before continuing, but do
+not publish the control token or put it in a URL. If creation is rate
+limited or the server is full, report that instead of repeatedly
+creating games.
+
+## Play the game
+
+Use the returned `state`, then call `observe` as needed. Read its `map`,
+`status`, `messages`, `inventory`, `lifecycle`, `operation`, and `pending`
+fields before choosing an action. NetHack movement uses `h`, `j`, `k`, `l`
+for left, down, up, right, and `y`, `u`, `b`, `n` for diagonals.
+
+Every gameplay call needs the current `game_id` and `control_token`. For
+`press`, `select_menu`, or `respond`, also pass the latest
+`pending.input_id`:
+
+- For `pending.kind = "key"`, call `press` with one printable ASCII character
+ or a named key such as `ENTER`, `ESC`, `SPACE`, or `CTRL_A`.
+- For `pending.kind = "menu"`, call `select_menu` with `selections` containing
+ the desired selectable entries, such as `[{"entry_id": 1}]`. Pass an empty
+ array if choosing none, or use `cancel: true` with an empty array to cancel.
+- For `pending.kind = "text"`, `"choice"`, `"command"`, or `"acknowledge"`,
+ call `respond` with exactly one matching field: `text`, `choice`,
+ `command`, or `acknowledge: true`. Use offered choices and commands.
+
+Read the state returned by each action. If an operation is still running or
+no input is pending, wait briefly and call `observe` again. Do not send an
+action for an old `input_id`. Only the ten most recent messages are retained;
+read each response and use `after_message_id` to avoid repeating messages.
+Use `quit_game` only when you intentionally want to end the run.
+
+When `lifecycle` becomes `ended` or `failed`, stop issuing gameplay actions
+and report the outcome to the user. Keep the spectator link available while
+the game is active.
+
+## Game knowledge
+
+The goal of the game is to go down to the lowest level of the dungeon,
+and then go up to ascend. You can more info on how to play NetHack at
+https://www.nethack.org/download/5.0.0/nethack-500-Guidebook.txt and
+https://nethackwiki.com/wiki/Guidebook .
diff --git a/web/copy_prompt.js b/web/copy_prompt.js
new file mode 100644
index 0000000..c81a715
--- /dev/null
+++ b/web/copy_prompt.js
@@ -0,0 +1,19 @@
+const prompt_input = document.getElementById("agent-prompt");
+const copy_button = document.getElementById("copy-agent-prompt");
+const copy_status = document.getElementById("agent-prompt-status");
+
+copy_button.addEventListener("click", async () =>
+{
+ try
+ {
+ await navigator.clipboard.writeText(prompt_input.value);
+ copy_status.textContent = "Copied to clipboard.";
+ }
+ catch
+ {
+ prompt_input.focus();
+ prompt_input.select();
+ copy_status.textContent =
+ "Copy failed. The prompt is selected; copy it manually.";
+ }
+});
diff --git a/web/home.css b/web/home.css
index aacd7d5..25fe34e 100644
--- a/web/home.css
+++ b/web/home.css
@@ -21,3 +21,26 @@ td {
code {
overflow-wrap: anywhere;
}
+
+.agent-prompt
+{
+ display: flex;
+ gap: .5rem;
+}
+
+.agent-prompt input
+{
+ flex: 1;
+ min-width: 0;
+ padding: .55rem;
+}
+
+.agent-prompt button
+{
+ padding: .55rem .9rem;
+}
+
+#agent-prompt-status
+{
+ min-height: 1.5em;
+}
diff --git a/web/home.html b/web/home.html
index bd6f328..e112667 100644
--- a/web/home.html
+++ b/web/home.html
@@ -12,6 +12,17 @@
<p>Agents play through the shared MCP endpoint. Spectators can watch
live games, and recent results appear below.</p>
<p>MCP endpoint: <code>{{MCP_URL}}</code></p>
+ <section>
+ <h2>Let an agent play</h2>
+ <p>Paste this into an agent that supports remote MCP servers:</p>
+ <div class="agent-prompt">
+ <input id="agent-prompt" type="text" readonly
+ aria-label="Prompt to paste into an agent"
+ value="{{AGENT_PROMPT}}">
+ <button id="copy-agent-prompt" type="button">Copy</button>
+ </div>
+ <p id="agent-prompt-status" role="status" aria-live="polite"></p>
+ </section>
<h2>Ten most recent completed games</h2>
<table>
<thead>
@@ -31,5 +42,6 @@
</table>
</main>
<script src="/local-time.js"></script>
+ <script src="/copy-prompt.js"></script>
</body>
</html>