@1claw/cli 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +4 -2
  2. package/dist/bundled-templates/shared/index.html +109 -0
  3. package/dist/bundled-templates/shared/spawn_chat_ui.py +343 -0
  4. package/dist/bundled-templates/shared/start-with-chat-ui.sh +33 -0
  5. package/dist/bundled-templates/templates/agentkit/Dockerfile +3 -0
  6. package/dist/bundled-templates/templates/agentkit/agent.py +1 -1
  7. package/dist/bundled-templates/templates/agentkit/entrypoint.sh +2 -2
  8. package/dist/bundled-templates/templates/agno/Dockerfile +3 -0
  9. package/dist/bundled-templates/templates/agno/agent.py +1 -1
  10. package/dist/bundled-templates/templates/agno/entrypoint.sh +2 -2
  11. package/dist/bundled-templates/templates/agno/requirements.txt +1 -1
  12. package/dist/bundled-templates/templates/coder/Dockerfile +3 -0
  13. package/dist/bundled-templates/templates/coder/agent.py +1 -1
  14. package/dist/bundled-templates/templates/coder/entrypoint.sh +2 -2
  15. package/dist/bundled-templates/templates/crewai/Dockerfile +3 -0
  16. package/dist/bundled-templates/templates/crewai/agent.py +1 -1
  17. package/dist/bundled-templates/templates/crewai/entrypoint.sh +2 -2
  18. package/dist/bundled-templates/templates/elizaos/Dockerfile +3 -0
  19. package/dist/bundled-templates/templates/elizaos/agent.ts +1 -1
  20. package/dist/bundled-templates/templates/elizaos/entrypoint.sh +2 -2
  21. package/dist/bundled-templates/templates/langchain/Dockerfile +3 -0
  22. package/dist/bundled-templates/templates/langchain/agent.py +1 -1
  23. package/dist/bundled-templates/templates/langchain/entrypoint.sh +2 -2
  24. package/dist/bundled-templates/templates/llamaindex/Dockerfile +3 -0
  25. package/dist/bundled-templates/templates/llamaindex/agent.py +1 -1
  26. package/dist/bundled-templates/templates/llamaindex/entrypoint.sh +2 -2
  27. package/dist/bundled-templates/templates/mastra/Dockerfile +4 -1
  28. package/dist/bundled-templates/templates/mastra/agent.ts +1 -1
  29. package/dist/bundled-templates/templates/mastra/entrypoint.sh +2 -2
  30. package/dist/bundled-templates/templates/openai-agents/Dockerfile +3 -0
  31. package/dist/bundled-templates/templates/openai-agents/agent.py +1 -1
  32. package/dist/bundled-templates/templates/openai-agents/entrypoint.sh +2 -2
  33. package/dist/bundled-templates/templates/pydantic-ai/Dockerfile +3 -0
  34. package/dist/bundled-templates/templates/pydantic-ai/agent.py +1 -1
  35. package/dist/bundled-templates/templates/pydantic-ai/entrypoint.sh +2 -2
  36. package/dist/bundled-templates/templates/smolagents/Dockerfile +3 -0
  37. package/dist/bundled-templates/templates/smolagents/README.md +1 -1
  38. package/dist/bundled-templates/templates/smolagents/SECURITY.md +17 -0
  39. package/dist/bundled-templates/templates/smolagents/agent.py +11 -3
  40. package/dist/bundled-templates/templates/smolagents/entrypoint.sh +2 -2
  41. package/dist/bundled-templates/templates/typescript-sdk/Dockerfile +4 -1
  42. package/dist/bundled-templates/templates/typescript-sdk/README.md +3 -0
  43. package/dist/bundled-templates/templates/typescript-sdk/agent.ts +164 -15
  44. package/dist/bundled-templates/templates/typescript-sdk/entrypoint.sh +2 -2
  45. package/dist/src/commands/init.d.ts.map +1 -1
  46. package/dist/src/commands/init.js +20 -21
  47. package/dist/src/commands/init.js.map +1 -1
  48. package/dist/src/commands/platform.js +90 -2
  49. package/dist/src/commands/platform.js.map +1 -1
  50. package/dist/src/commands/proxy.d.ts.map +1 -1
  51. package/dist/src/commands/proxy.js +7 -23
  52. package/dist/src/commands/proxy.js.map +1 -1
  53. package/dist/src/commands/spawn.d.ts.map +1 -1
  54. package/dist/src/commands/spawn.js +39 -19
  55. package/dist/src/commands/spawn.js.map +1 -1
  56. package/dist/src/lib/agent-key.d.ts +13 -0
  57. package/dist/src/lib/agent-key.d.ts.map +1 -0
  58. package/dist/src/lib/agent-key.js +45 -0
  59. package/dist/src/lib/agent-key.js.map +1 -0
  60. package/dist/src/lib/docker-client.js +1 -1
  61. package/dist/src/lib/docker-client.js.map +1 -1
  62. package/package.json +1 -1
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @1claw/cli (v0.56.3)
1
+ # @1claw/cli (v0.57.0)
2
2
 
3
3
  Command-line interface for [1Claw](https://1claw.xyz).
4
4
 
@@ -1113,7 +1113,7 @@ conflicts: [] # e.g. [other-tool] to forbid combining
1113
1113
 
1114
1114
  ## Agent Templates (`spawn`)
1115
1115
 
1116
- `1claw spawn` creates a framework-specific AI agent from a pre-built template. Each template ships a Dockerfile, starter code, and entrypoint pre-wired with 1Claw MCP and Shroud. The container follows the same daemon-socket security model as `init --docker` — **the container never sees raw API keys.**
1116
+ `1claw spawn` creates a framework-specific AI agent from a pre-built template. Each template ships a Dockerfile, starter code, and entrypoint pre-wired with 1Claw MCP and Shroud. After spawn, an **interactive browser chat UI** opens at `http://localhost:<port>` (default 3000) so you can send test messages immediately. The container follows the same daemon-socket security model as `init --docker` — **the container never sees raw API keys.**
1117
1117
 
1118
1118
  ```bash
1119
1119
  1claw spawn langchain # LangChain agent with Shroud LLM routing
@@ -1123,6 +1123,8 @@ conflicts: [] # e.g. [other-tool] to forbid combining
1123
1123
  1claw spawn --refresh # Force-refresh templates from GitHub
1124
1124
  ```
1125
1125
 
1126
+ When the health check passes, spawn prints the local URL and opens your browser to the shared chat UI. Framework logic runs on an internal port; the UI forwards `POST /chat` to your agent or falls back to Shroud through the host daemon `/proxy`.
1127
+
1126
1128
  ### Available templates
1127
1129
 
1128
1130
  **Python:**
@@ -0,0 +1,109 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>1Claw __FRAMEWORK__</title>
7
+ <style>
8
+ :root { color-scheme: dark; }
9
+ * { box-sizing: border-box; }
10
+ body {
11
+ margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
12
+ background: #0b0d12; color: #e6e9ef; height: 100vh; display: flex; flex-direction: column;
13
+ }
14
+ header {
15
+ padding: 12px 18px; border-bottom: 1px solid #1c2230;
16
+ display: flex; align-items: center; gap: 12px; background: #0e1219;
17
+ }
18
+ header .dot { width: 9px; height: 9px; border-radius: 50%; background: #38d39f; box-shadow: 0 0 8px #38d39f; }
19
+ header h1 { font-size: 15px; margin: 0; font-weight: 600; }
20
+ header .meta { margin-left: auto; font-size: 12px; color: #7c869b; }
21
+ header .badge { background: #1c2230; padding: 2px 8px; border-radius: 999px; margin-left: 6px; }
22
+ #log { flex: 1; overflow-y: auto; padding: 18px; display: flex; flex-direction: column; gap: 12px; }
23
+ .msg { max-width: 80%; padding: 10px 14px; border-radius: 12px; white-space: pre-wrap; line-height: 1.45; font-size: 14px; }
24
+ .user { align-self: flex-end; background: #2563eb; color: #fff; border-bottom-right-radius: 3px; }
25
+ .bot { align-self: flex-start; background: #161b25; border: 1px solid #1c2230; border-bottom-left-radius: 3px; }
26
+ .tool { align-self: flex-start; font-size: 11px; color: #8aa0c2; margin-top: -6px; }
27
+ form { display: flex; gap: 8px; padding: 14px 18px; border-top: 1px solid #1c2230; background: #0e1219; }
28
+ input { flex: 1; background: #0b0d12; border: 1px solid #28303f; color: #e6e9ef; padding: 11px 14px; border-radius: 10px; font-size: 14px; outline: none; }
29
+ input:focus { border-color: #2563eb; }
30
+ button { background: #2563eb; color: #fff; border: none; padding: 0 18px; border-radius: 10px; font-size: 14px; cursor: pointer; font-weight: 600; }
31
+ button:disabled { opacity: .5; cursor: default; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <header>
36
+ <span class="dot"></span>
37
+ <h1>1Claw __FRAMEWORK__</h1>
38
+ <span class="meta" id="meta">connecting…</span>
39
+ </header>
40
+ <div id="log"></div>
41
+ <form id="form">
42
+ <input id="input" placeholder="Type a message or /help" autocomplete="off" autofocus />
43
+ <button id="send" type="submit">Send</button>
44
+ </form>
45
+ <script>
46
+ const log = document.getElementById("log");
47
+ const form = document.getElementById("form");
48
+ const input = document.getElementById("input");
49
+ const send = document.getElementById("send");
50
+ const meta = document.getElementById("meta");
51
+
52
+ function add(role, text, tool) {
53
+ if (tool) {
54
+ const t = document.createElement("div");
55
+ t.className = "tool";
56
+ t.textContent = "⚙ " + tool;
57
+ log.appendChild(t);
58
+ }
59
+ const el = document.createElement("div");
60
+ el.className = "msg " + (role === "user" ? "user" : "bot");
61
+ el.textContent = text;
62
+ log.appendChild(el);
63
+ log.scrollTop = log.scrollHeight;
64
+ }
65
+
66
+ async function loadInfo() {
67
+ try {
68
+ const r = await fetch("/api/info");
69
+ const info = await r.json();
70
+ const parts = [];
71
+ if (info.framework) parts.push(info.framework);
72
+ parts.push("vault=" + info.mode);
73
+ if (info.agentId) parts.push("agent=" + info.agentId.slice(0, 8) + "…");
74
+ parts.push(info.daemonReachable ? "daemon ✓" : "daemon —");
75
+ meta.innerHTML = parts.map((p) => '<span class="badge">' + p + "</span>").join("");
76
+ } catch {
77
+ meta.textContent = "offline";
78
+ }
79
+ }
80
+
81
+ form.addEventListener("submit", async (e) => {
82
+ e.preventDefault();
83
+ const message = input.value.trim();
84
+ if (!message) return;
85
+ add("user", message);
86
+ input.value = "";
87
+ send.disabled = true;
88
+ try {
89
+ const r = await fetch("/api/chat", {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({ message }),
93
+ });
94
+ const data = await r.json();
95
+ add("bot", data.reply || "(no reply)", data.tool);
96
+ } catch (err) {
97
+ add("bot", "Error: " + err.message);
98
+ } finally {
99
+ send.disabled = false;
100
+ input.focus();
101
+ }
102
+ });
103
+
104
+ add("bot", "1Claw agent ready. Credentials stay in the host daemon — this container never sees secret values. Type /help to begin.");
105
+ loadInfo();
106
+ setInterval(loadInfo, 15000);
107
+ </script>
108
+ </body>
109
+ </html>
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Minimal browser chat UI for `1claw spawn` templates.
4
+
5
+ Listens on CHAT_UI_PORT (default 3000). Forwards chat to the framework agent on
6
+ AGENT_INTERNAL_PORT (default 3001) when available, otherwise falls back to Shroud
7
+ via the host daemon /proxy (credentials never enter the container).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import socket
15
+ import sys
16
+ import threading
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ from pathlib import Path
19
+ from typing import Any
20
+ from urllib.error import HTTPError, URLError
21
+ from urllib.request import Request, urlopen
22
+
23
+ PORT = int(os.environ.get("CHAT_UI_PORT", "3000"))
24
+ AGENT_PORT = int(os.environ.get("AGENT_INTERNAL_PORT", "3001"))
25
+ SOCKET_PATH = os.environ.get("ONECLAW_DAEMON_SOCKET", "/run/1claw/daemon.sock")
26
+ SECRET_PREFIX = os.environ.get("ONECLAW_SECRET_PREFIX", "")
27
+ AGENT_ID = os.environ.get("ONECLAW_AGENT_ID", "")
28
+ FRAMEWORK = os.environ.get("ONECLAW_FRAMEWORK", "agent")
29
+ MODE = os.environ.get(
30
+ "ONECLAW_MODE",
31
+ "local" if os.environ.get("ONECLAW_LOCAL_VAULT") == "true" else "cloud",
32
+ )
33
+
34
+ LLM_VIA_SHROUD = os.environ.get("ONECLAW_LLM_VIA_SHROUD", "").lower() == "true"
35
+ SHROUD_URL = (os.environ.get("ONECLAW_SHROUD_URL") or "https://shroud.1claw.xyz").rstrip("/")
36
+ SHROUD_SECRET = os.environ.get("ONECLAW_SHROUD_SECRET", "")
37
+ SHROUD_API_KEY_SECRET = os.environ.get("ONECLAW_SHROUD_API_KEY_SECRET", "")
38
+ SHROUD_PROVIDER = os.environ.get("ONECLAW_SHROUD_PROVIDER", "openai")
39
+ SHROUD_MODEL = os.environ.get("ONECLAW_SHROUD_MODEL", "gpt-4o-mini")
40
+ SYSTEM_PROMPT = os.environ.get(
41
+ "ONECLAW_SHROUD_SYSTEM_PROMPT",
42
+ "You are a helpful AI agent running inside a 1Claw secure container. "
43
+ "Your credentials are held by the host daemon and never exposed to you.",
44
+ )
45
+
46
+ HISTORY_MAX = 20
47
+ _conversation: list[dict[str, str]] = []
48
+ _lock = threading.Lock()
49
+
50
+ _INDEX_HTML = Path(__file__).with_name("index.html")
51
+ if _INDEX_HTML.is_file():
52
+ INDEX_HTML = _INDEX_HTML.read_text(encoding="utf-8")
53
+ else:
54
+ INDEX_HTML = "<h1>1Claw Agent</h1>"
55
+
56
+
57
+ def _daemon_request(method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, dict[str, Any] | None]:
58
+ payload = json.dumps(body).encode("utf-8") if body is not None else None
59
+ headers = [
60
+ f"{method} {path} HTTP/1.1",
61
+ "Host: localhost",
62
+ "Content-Type: application/json",
63
+ "Connection: close",
64
+ ]
65
+ if SECRET_PREFIX:
66
+ headers.append(f"X-Secret-Prefix: {SECRET_PREFIX}")
67
+ if payload is not None:
68
+ headers.append(f"Content-Length: {len(payload)}")
69
+ headers.append("")
70
+ headers.append("")
71
+ raw = "\r\n".join(headers).encode("utf-8") + (payload or b"")
72
+
73
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
74
+ sock.settimeout(120)
75
+ try:
76
+ sock.connect(SOCKET_PATH)
77
+ sock.sendall(raw)
78
+ chunks: list[bytes] = []
79
+ while True:
80
+ part = sock.recv(65536)
81
+ if not part:
82
+ break
83
+ chunks.append(part)
84
+ finally:
85
+ sock.close()
86
+
87
+ text = b"".join(chunks).decode("utf-8", errors="replace")
88
+ if "\r\n\r\n" not in text:
89
+ return 0, None
90
+ _, body_text = text.split("\r\n\r\n", 1)
91
+ status_line = text.split("\r\n", 1)[0]
92
+ try:
93
+ status = int(status_line.split()[1])
94
+ except (IndexError, ValueError):
95
+ status = 0
96
+ try:
97
+ parsed = json.loads(body_text) if body_text.strip() else None
98
+ except json.JSONDecodeError:
99
+ parsed = {"raw": body_text}
100
+ return status, parsed
101
+
102
+
103
+ def _daemon_reachable() -> bool:
104
+ try:
105
+ status, _ = _daemon_request("GET", "/health")
106
+ return status == 200
107
+ except OSError:
108
+ return False
109
+
110
+
111
+ def _shroud_chat(user_text: str) -> tuple[bool, str]:
112
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}, *_conversation, {"role": "user", "content": user_text}]
113
+ proxy_body: dict[str, Any] = {
114
+ "secretName": SHROUD_SECRET,
115
+ "url": f"{SHROUD_URL}/v1/chat/completions",
116
+ "method": "POST",
117
+ "headers": {
118
+ "Content-Type": "application/json",
119
+ "X-Shroud-Provider": SHROUD_PROVIDER,
120
+ },
121
+ "body": json.dumps({"model": SHROUD_MODEL, "messages": messages}),
122
+ }
123
+ if SHROUD_API_KEY_SECRET:
124
+ proxy_body["injectSecrets"] = [SHROUD_API_KEY_SECRET]
125
+
126
+ status, body = _daemon_request("POST", "/proxy", proxy_body)
127
+ if status != 200 or not body:
128
+ detail = (body or {}).get("error") if body else "unknown daemon error"
129
+ return False, f"Daemon refused the LLM call ({status}): {detail}"
130
+
131
+ upstream_status = body.get("status", 502)
132
+ raw_upstream = body.get("body", "")
133
+ try:
134
+ upstream = json.loads(raw_upstream) if isinstance(raw_upstream, str) else raw_upstream
135
+ except json.JSONDecodeError:
136
+ upstream = {"raw": raw_upstream}
137
+
138
+ if upstream_status != 200:
139
+ err = upstream.get("error") if isinstance(upstream, dict) else upstream
140
+ if isinstance(err, dict):
141
+ msg = err.get("message") or str(err)
142
+ else:
143
+ msg = str(err or upstream)
144
+ return False, f"Shroud/upstream returned {upstream_status}: {msg}"
145
+
146
+ content = (
147
+ upstream.get("choices", [{}])[0]
148
+ .get("message", {})
149
+ .get("content")
150
+ if isinstance(upstream, dict)
151
+ else None
152
+ )
153
+ if not content:
154
+ return False, f"No content in model response: {str(upstream)[:400]}"
155
+ return True, str(content)
156
+
157
+
158
+ def _agent_chat(user_text: str) -> dict[str, Any] | None:
159
+ req = Request(
160
+ f"http://127.0.0.1:{AGENT_PORT}/chat",
161
+ data=json.dumps({"message": user_text}).encode("utf-8"),
162
+ headers={"Content-Type": "application/json"},
163
+ method="POST",
164
+ )
165
+ try:
166
+ with urlopen(req, timeout=120) as resp:
167
+ data = json.loads(resp.read().decode("utf-8"))
168
+ except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, OSError):
169
+ return None
170
+
171
+ if isinstance(data, dict) and data.get("response"):
172
+ return {
173
+ "reply": str(data["response"]),
174
+ "tool": data.get("framework") or FRAMEWORK,
175
+ }
176
+ if isinstance(data, dict) and data.get("error"):
177
+ return {"reply": str(data["error"]), "tool": "agent_error"}
178
+ return None
179
+
180
+
181
+ def handle_chat(message: str) -> dict[str, Any]:
182
+ text = (message or "").strip()
183
+
184
+ if text in ("", "/help"):
185
+ llm_line = (
186
+ f"LLM is wired via Shroud ({SHROUD_PROVIDER}/{SHROUD_MODEL}) — type a message to chat.\n"
187
+ if LLM_VIA_SHROUD and SHROUD_SECRET
188
+ else "Framework agent on port "
189
+ f"{AGENT_PORT} handles chat when running; Shroud fallback uses the daemon.\n"
190
+ )
191
+ return {
192
+ "reply": (
193
+ f"1Claw {FRAMEWORK} agent ready.\n"
194
+ f"{llm_line}"
195
+ "Commands:\n"
196
+ " /secrets — list secret names available via the daemon\n"
197
+ " /info — show runtime info\n"
198
+ "Secret values never enter this container."
199
+ ),
200
+ "tool": None,
201
+ }
202
+
203
+ if text == "/info":
204
+ llm = f" · llm=shroud:{SHROUD_PROVIDER}/{SHROUD_MODEL}" if LLM_VIA_SHROUD else " · llm=agent"
205
+ return {
206
+ "reply": (
207
+ f"Agent {AGENT_ID or '(local)'} · framework={FRAMEWORK} · "
208
+ f"vault={MODE} · chat_ui=:{PORT} · agent=:{AGENT_PORT}{llm}"
209
+ ),
210
+ "tool": "info",
211
+ }
212
+
213
+ if text == "/secrets":
214
+ path = f"/secrets?prefix={SECRET_PREFIX}" if SECRET_PREFIX else "/secrets"
215
+ try:
216
+ status, body = _daemon_request("GET", path)
217
+ if status != 200:
218
+ return {"reply": f"Daemon returned {status}", "tool": "list_secrets"}
219
+ names = [s.get("name", "") for s in (body or {}).get("secrets", [])]
220
+ names = [n for n in names if n]
221
+ reply = (
222
+ "Available secrets:\n" + "\n".join(f" • {n}" for n in names)
223
+ if names
224
+ else "No secrets available to this agent."
225
+ )
226
+ return {"reply": reply, "tool": "list_secrets"}
227
+ except OSError as exc:
228
+ return {"reply": f"Could not reach daemon: {exc}", "tool": "list_secrets"}
229
+
230
+ agent_out = _agent_chat(text)
231
+ if agent_out is not None:
232
+ return agent_out
233
+
234
+ if LLM_VIA_SHROUD and SHROUD_SECRET:
235
+ try:
236
+ ok, reply = _shroud_chat(text)
237
+ if ok:
238
+ with _lock:
239
+ _conversation.append({"role": "user", "content": text})
240
+ _conversation.append({"role": "assistant", "content": reply})
241
+ while len(_conversation) > HISTORY_MAX:
242
+ _conversation.pop(0)
243
+ return {"reply": reply, "tool": "shroud_llm"}
244
+ except OSError as exc:
245
+ return {"reply": f"Could not reach the daemon for LLM call: {exc}", "tool": "shroud_llm"}
246
+
247
+ return {
248
+ "reply": (
249
+ "No LLM configured. Re-run spawn with --agent-key ocv_... "
250
+ "and optional --llm-api-key. The host daemon injects Shroud credentials."
251
+ ),
252
+ "tool": None,
253
+ }
254
+
255
+
256
+ class Handler(BaseHTTPRequestHandler):
257
+ def log_message(self, fmt: str, *args: Any) -> None:
258
+ sys.stderr.write(f"[spawn-chat-ui] {self.address_string()} - {fmt % args}\n")
259
+
260
+ def _json(self, status: int, obj: dict[str, Any]) -> None:
261
+ body = json.dumps(obj).encode("utf-8")
262
+ self.send_response(status)
263
+ self.send_header("Content-Type", "application/json")
264
+ self.send_header("Content-Length", str(len(body)))
265
+ self.end_headers()
266
+ self.wfile.write(body)
267
+
268
+ def do_GET(self) -> None:
269
+ path = self.path.split("?", 1)[0]
270
+ if path == "/health":
271
+ self._json(
272
+ 200,
273
+ {
274
+ "status": "ok",
275
+ "framework": FRAMEWORK,
276
+ "agent_port": AGENT_PORT,
277
+ "llm_wired": LLM_VIA_SHROUD and bool(SHROUD_SECRET),
278
+ },
279
+ )
280
+ return
281
+ if path == "/api/info":
282
+ self._json(
283
+ 200,
284
+ {
285
+ "agentId": AGENT_ID or None,
286
+ "framework": FRAMEWORK,
287
+ "runtime": "docker",
288
+ "mode": MODE,
289
+ "agentPort": AGENT_PORT,
290
+ "llm": (
291
+ {
292
+ "via": "shroud",
293
+ "provider": SHROUD_PROVIDER,
294
+ "model": SHROUD_MODEL,
295
+ }
296
+ if LLM_VIA_SHROUD
297
+ else None
298
+ ),
299
+ "daemonReachable": _daemon_reachable(),
300
+ },
301
+ )
302
+ return
303
+ if path in ("/", "/index.html"):
304
+ html = INDEX_HTML.replace("__FRAMEWORK__", FRAMEWORK)
305
+ body = html.encode("utf-8")
306
+ self.send_response(200)
307
+ self.send_header("Content-Type", "text/html; charset=utf-8")
308
+ self.send_header("Content-Length", str(len(body)))
309
+ self.end_headers()
310
+ self.wfile.write(body)
311
+ return
312
+ self._json(404, {"error": "not found"})
313
+
314
+ def do_POST(self) -> None:
315
+ path = self.path.split("?", 1)[0]
316
+ if path != "/api/chat":
317
+ self._json(404, {"error": "not found"})
318
+ return
319
+ length = int(self.headers.get("Content-Length", "0"))
320
+ if length > 1024 * 1024:
321
+ self._json(413, {"error": "body too large"})
322
+ return
323
+ try:
324
+ payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
325
+ except json.JSONDecodeError:
326
+ self._json(400, {"error": "invalid JSON"})
327
+ return
328
+ result = handle_chat(str(payload.get("message", "")))
329
+ self._json(200, result)
330
+
331
+
332
+ def main() -> None:
333
+ server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
334
+ print(
335
+ f"[spawn-chat-ui] listening on :{PORT} "
336
+ f"(framework={FRAMEWORK}, agent=:{AGENT_PORT}, mode={MODE})",
337
+ flush=True,
338
+ )
339
+ server.serve_forever()
340
+
341
+
342
+ if __name__ == "__main__":
343
+ main()
@@ -0,0 +1,33 @@
1
+ #!/bin/sh
2
+ # Start the framework agent on AGENT_INTERNAL_PORT, then run the shared chat UI
3
+ # on CHAT_UI_PORT (spawn publishes this port to localhost).
4
+ set -e
5
+
6
+ AGENT_INTERNAL_PORT="${AGENT_INTERNAL_PORT:-3001}"
7
+ CHAT_UI_PORT="${CHAT_UI_PORT:-3000}"
8
+ export AGENT_INTERNAL_PORT CHAT_UI_PORT
9
+
10
+ if [ $# -lt 1 ]; then
11
+ echo "Usage: start-with-chat-ui.sh <agent command...>" >&2
12
+ exit 1
13
+ fi
14
+
15
+ "$@" &
16
+ AGENT_PID=$!
17
+
18
+ cleanup() {
19
+ kill "$AGENT_PID" 2>/dev/null || true
20
+ }
21
+ trap cleanup EXIT INT TERM
22
+
23
+ i=0
24
+ while [ "$i" -lt 30 ]; do
25
+ if curl -sf "http://127.0.0.1:${AGENT_INTERNAL_PORT}/health" >/dev/null 2>&1; then
26
+ break
27
+ fi
28
+ i=$((i + 1))
29
+ sleep 0.5
30
+ done
31
+
32
+ echo "Ready: http://0.0.0.0:${CHAT_UI_PORT} (chat UI → agent on :${AGENT_INTERNAL_PORT})"
33
+ exec python3 /app/shared/spawn_chat_ui.py
@@ -6,6 +6,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
6
6
 
7
7
  WORKDIR /app
8
8
 
9
+ COPY shared /app/shared
10
+ RUN chmod +x /app/shared/start-with-chat-ui.sh
11
+
9
12
  COPY requirements.txt .
10
13
  RUN pip install --no-cache-dir -r requirements.txt
11
14
 
@@ -78,6 +78,6 @@ Credentials stay in the host daemon — this container never sees secret values.
78
78
 
79
79
 
80
80
  if __name__ == "__main__":
81
- port = int(os.environ.get("CHAT_UI_PORT", "3000"))
81
+ port = int(os.environ.get("AGENT_INTERNAL_PORT", "3001"))
82
82
  print(f"AgentKit agent listening on http://0.0.0.0:{port}")
83
83
  app.run(host="0.0.0.0", port=port)
@@ -30,5 +30,5 @@ if [ -S "$DAEMON_SOCKET" ]; then
30
30
  fi
31
31
  fi
32
32
 
33
- echo "Ready: http://0.0.0.0:${CHAT_UI_PORT}"
34
- exec python agent.py
33
+ # Chat UI starts in start-with-chat-ui.sh
34
+ exec /app/shared/start-with-chat-ui.sh python agent.py
@@ -6,6 +6,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
6
6
 
7
7
  WORKDIR /app
8
8
 
9
+ COPY shared /app/shared
10
+ RUN chmod +x /app/shared/start-with-chat-ui.sh
11
+
9
12
  COPY requirements.txt .
10
13
  RUN pip install --no-cache-dir -r requirements.txt
11
14
 
@@ -81,6 +81,6 @@ Credentials stay in the host daemon — this container never sees secret values.
81
81
 
82
82
 
83
83
  if __name__ == "__main__":
84
- port = int(os.environ.get("CHAT_UI_PORT", "3000"))
84
+ port = int(os.environ.get("AGENT_INTERNAL_PORT", "3001"))
85
85
  print(f"Agno agent listening on http://0.0.0.0:{port}")
86
86
  app.run(host="0.0.0.0", port=port)
@@ -30,5 +30,5 @@ if [ -S "$DAEMON_SOCKET" ]; then
30
30
  fi
31
31
  fi
32
32
 
33
- echo "Ready: http://0.0.0.0:${CHAT_UI_PORT}"
34
- exec python agent.py
33
+ # Chat UI starts in start-with-chat-ui.sh
34
+ exec /app/shared/start-with-chat-ui.sh python agent.py
@@ -1,3 +1,3 @@
1
- agno>=1.0.0
1
+ agno>=1.7.0,<2
2
2
  flask>=3.0.0
3
3
  oneclaw>=0.42.0
@@ -6,6 +6,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
6
6
 
7
7
  WORKDIR /app
8
8
 
9
+ COPY shared /app/shared
10
+ RUN chmod +x /app/shared/start-with-chat-ui.sh
11
+
9
12
  COPY requirements.txt .
10
13
  RUN pip install --no-cache-dir -r requirements.txt
11
14
 
@@ -114,6 +114,6 @@ Credentials stay in the host daemon — this container never sees secret values.
114
114
 
115
115
 
116
116
  if __name__ == "__main__":
117
- port = int(os.environ.get("CHAT_UI_PORT", "3000"))
117
+ port = int(os.environ.get("AGENT_INTERNAL_PORT", "3001"))
118
118
  print(f"Coder agent listening on http://0.0.0.0:{port}")
119
119
  app.run(host="0.0.0.0", port=port)
@@ -30,5 +30,5 @@ if [ -S "$DAEMON_SOCKET" ]; then
30
30
  fi
31
31
  fi
32
32
 
33
- echo "Ready: http://0.0.0.0:${CHAT_UI_PORT}"
34
- exec python agent.py
33
+ # Chat UI starts in start-with-chat-ui.sh
34
+ exec /app/shared/start-with-chat-ui.sh python agent.py
@@ -6,6 +6,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
6
6
 
7
7
  WORKDIR /app
8
8
 
9
+ COPY shared /app/shared
10
+ RUN chmod +x /app/shared/start-with-chat-ui.sh
11
+
9
12
  COPY requirements.txt .
10
13
  RUN pip install --no-cache-dir -r requirements.txt
11
14
 
@@ -104,6 +104,6 @@ Credentials stay in the host daemon — this container never sees secret values.
104
104
 
105
105
 
106
106
  if __name__ == "__main__":
107
- port = int(os.environ.get("CHAT_UI_PORT", "3000"))
107
+ port = int(os.environ.get("AGENT_INTERNAL_PORT", "3001"))
108
108
  print(f"CrewAI agent listening on http://0.0.0.0:{port}")
109
109
  app.run(host="0.0.0.0", port=port)
@@ -30,5 +30,5 @@ if [ -S "$DAEMON_SOCKET" ]; then
30
30
  fi
31
31
  fi
32
32
 
33
- echo "Ready: http://0.0.0.0:${CHAT_UI_PORT}"
34
- exec python agent.py
33
+ # Chat UI starts in start-with-chat-ui.sh
34
+ exec /app/shared/start-with-chat-ui.sh python agent.py
@@ -6,6 +6,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
6
6
 
7
7
  WORKDIR /app
8
8
 
9
+ COPY shared /app/shared
10
+ RUN chmod +x /app/shared/start-with-chat-ui.sh
11
+
9
12
  COPY package.json tsconfig.json ./
10
13
  RUN npm install --omit=dev 2>/dev/null || npm install
11
14
 
@@ -11,7 +11,7 @@ const SHROUD_URL = process.env.ONECLAW_SHROUD_URL ?? "https://shroud.1claw.xyz";
11
11
  const LLM_VIA_SHROUD = process.env.ONECLAW_LLM_VIA_SHROUD === "true";
12
12
  const PROVIDER = process.env.ONECLAW_SHROUD_PROVIDER ?? "openai";
13
13
  const MODEL = process.env.ONECLAW_SHROUD_MODEL ?? "gpt-4o-mini";
14
- const PORT = parseInt(process.env.CHAT_UI_PORT ?? "3000", 10);
14
+ const PORT = parseInt(process.env.AGENT_INTERNAL_PORT ?? "3001", 10);
15
15
 
16
16
  const conversationHistory: { role: "user" | "assistant"; content: string }[] = [];
17
17
 
@@ -30,5 +30,5 @@ if [ -S "$DAEMON_SOCKET" ]; then
30
30
  fi
31
31
  fi
32
32
 
33
- echo "Ready: http://0.0.0.0:${CHAT_UI_PORT}"
34
- exec npx tsx agent.ts
33
+ # Chat UI starts in start-with-chat-ui.sh
34
+ exec /app/shared/start-with-chat-ui.sh npx tsx agent.ts