feat: add web interface served from docker container
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 35/100
- Issue type
- Feature
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- javascript, playwright, tailwindcss, vite
- Domain
- api, full-stack, testing, web-dev
Research direction
Start with index.js and the existing TUI entry points named in the proposal, then inspect src/tui/conversationArea.js, skillsPanel.js, memoryPanel.js, sessionsPanel.js, projectsPanel.js, settingsPanel.js, and src/provider/tokenBudget.js. Review tests/integration/api.test.js before designing the Fastify routes in src/web/server.js and the Vite frontend in web/. Done means the documented API, six views, streaming behavior, silent skill execution, and unit, integration, and Playwright tests are implemented.
Written by the indexing model from the issue text.
Description
Summary
Add a web mode to madz that serves a responsive browser interface from the Docker container, exposing the same core engine the TUI uses — chat, slash commands, skills, and token rate limiting — via a Fastify server and a Vite + Tailwind frontend. The API surface is a 1:1 mirror of every banner/help command, and includes a dedicated silent skill-execution endpoint.
Motivation
The core engine is currently only reachable through the Ink TUI, which requires an interactive terminal session. A web interface lets the service run independently of any TUI instance — the container can serve the app over HTTP while multiple clients connect. Sessions are already persisted to disk and resumable, so the web layer is a new transport over the existing engine rather than a new capability.
The API must be a 1:1 mirror of the TUI's command surface, not a chat-only transport. Every command in the banner and /help needs its own endpoint so the web UI can replicate the full TUI functionality. Skill execution must be a true silent invocation — dispatched by name without injecting a synthesized message into the conversation context window.
Proposed Solution
Add a --mode web (or --serve) branch to index.js that reuses the already-initialized agent, sessionState, and dispatchProvider instead of rendering the TUI. The web layer consists of:
- Fastify server (
src/web/server.js) — one endpoint per TUI command (see API Surface below). - Vite + Tailwind frontend (
web/) — a responsive single-page chat UI mirroring the TUI's features: message bubbles, reasoning side-channel, tool call display, slash commands, skills panel, and token rate-limit indicator. - SSE streaming — maps 1:1 to the existing
dispatchProviderstreaming callback (message,reasoning,on_tool_start,on_tool_end).
Sessions remain stored on disk and resumable from the TUI — no change to the session model. Local-only, no auth.
API Surface (session-scoped)
The session is the resource. All conversation happens within a session — a message cannot exist outside one. The session ID lives in the URL path. The client holds the session ID (from POST /api/sessions when creating, or GET /api/sessions when resuming) and sends every message to that session.
Sessions:
| Endpoint | Description |
|---|---|
POST /api/sessions |
Create a new session → { id } |
GET /api/sessions |
List sessions → [{ id, topic, updatedAt }] |
GET /api/sessions/:id |
Get a session (frontmatter + messages) → { id, messages: [{ role, content, timestamp, reasoningContent? }] } |
Messages (nested under a session):
| Endpoint | Description |
|---|---|
POST /api/sessions/:id/messages |
Send a message to the session, stream SSE back. This is the chat. |
POST /api/sessions/:id/interrupt |
Abort the in-flight stream for this session |
How a conversation happens:
- Client calls
POST /api/sessions→ gets{ id }. - Client calls
POST /api/sessions/:id/messageswith{ content }→ server loads that session's conversation into the session state, dispatches viadispatchProvider, and streams SSE events (message,reasoning,on_tool_start,on_tool_end). The exchange is appended to the session and persisted. - To continue, client calls the same
POST /api/sessions/:id/messagesagain — the session ID carries the context. - To interrupt, client calls
POST /api/sessions/:id/interrupt— aborts that session's in-flight stream.
Global (not session-scoped) commands:
| TUI | Endpoint | Description |
|---|---|---|
/config set <path> <value> |
GET /api/config, PATCH /api/config |
Read/update config |
/gc [status] |
POST /api/gc, GET /api/gc/status |
Run GC / show status |
/help |
GET /api/help |
Command help |
/memories |
GET /api/memories |
Memories panel data |
/provider [set <name>] |
GET /api/provider, PATCH /api/provider |
List/switch provider |
/projects |
GET /api/projects, PATCH /api/projects/select |
Projects panel data / set active |
/projects clear |
PATCH /api/projects/clear |
Clear active project |
/quit, /exit |
POST /api/quit |
Exit the app |
/schedule [list|pause|resume|run-now] |
GET /api/schedule, PATCH /api/schedule/:action |
Manage schedules |
/settings |
GET /api/settings |
Settings panel data |
/skills |
GET /api/skills |
Skills panel data |
/skillName [args] |
POST /api/skills/:name/execute |
Execute a skill by name (silent) |
Token rate limit:
| TUI | Endpoint | Description |
|---|---|---|
| Status bar token counter | GET /api/tokens |
Live rolling token usage + budget ceiling |
Skill execution (silent):
POST /api/skills/:name/execute — invoke a skill by name WITHOUT injecting a synthesized user message into the context window. This is distinct from the current TUI /skill path, which synthesizes "Run the <skill> skill" and calls sessionState.addExchange({ role: "user", content: ... }) — polluting the conversation context. The silent path dispatches the skill directly.
SPA Specification
The frontend is a single-page app built with Vite + Tailwind, served statically by @fastify/static. It must replicate the TUI's six views. Each view has a defined data contract from the API.
View 1 — Conversation (default)
- Message bubbles — user/assistant/system roles, streaming state, timestamps.
- Reasoning side-channel — collapsible
💭panel forreasoningsegments, rendered offset so it does not fragment the message flow. - Tool call display —
on_tool_start/on_tool_end/tool_result/on_tool_errorevents rendered as inline tool-call chips (name + status), matchingcreateStreamingHandlerinsrc/tui/conversationArea.js:591-700. - Markdown rendering — assistant content rendered as markdown (the TUI uses
markedviasrc/tui/markdownText.js). - Slash command input —
/triggers command autocomplete; unknown/skillNameroutes toPOST /api/skills/:name/execute. - Interrupt — a stop button that calls
POST /api/sessions/:id/interrupt. - Data source:
POST /api/sessions/:id/messages(SSE),GET /api/sessions/:id.
View 2 — Skills panel
- List registered skills with name + description (truncated at 500 chars, matching
DESCRIPTION_MAX_LENGTHinsrc/tui/skillsPanel.js). - Select a skill → invoke via
POST /api/skills/:name/execute. - Data source:
GET /api/skills→{ skills: [{ name, description, location }] }(fromregistry.getCatalog()).
View 3 — Memory panel
- Browse canonical user memories from
memory/context/, excludingephemeral-*.md,reflection.md,clarifications.md(matchingsrc/tui/memoryPanel.js). - Select an entry → view its full content.
- Data source:
GET /api/memories→{ entries: [{ id, title, content, updatedAt }] }.
View 4 — Settings panel
- Read-only config section browser with collapsible groups (matching
src/tui/settingsPanel.js). - Flatten nested config into
{ path, value }pairs. - Data source:
GET /api/settings→{ sections: [{ name, entries: [{ path, value }] }] }.
View 5 — Sessions panel
- List past sessions from
memory/sessions/, synthesizing a topic from the first user message (matchingsrc/tui/sessionsPanel.js). - Select a session → resume it.
- Data source:
GET /api/sessions→{ sessions: [{ id, topic, updatedAt }] };GET /api/sessions/:id→{ id, messages: [{ role, content, timestamp, reasoningContent? }] }.
View 6 — Projects panel
- List directories under
projects/, excluding*.worktrees(matchingsrc/tui/projectsPanel.js). - Select a project → set active; clear → reset.
- Data source:
GET /api/projects→{ projects: [{ name, path }] };PATCH /api/projects/select;PATCH /api/projects/clear.
Status bar
- Model name, skill count, message count, context size, token usage, version, active project.
- Token rate-limit indicator:
GET /api/tokens→{ current, maxTokensMinute }(live rolling usage from the sharedTokenBudget+ the configured ceiling). The TUI reads the ceiling fromconfig.providers[].rateLimit.maxTokensMinute(src/tui/app.js:60) and the live usage frombudget.current()(src/provider/tokenBudget.js).
Responsive layout
- Mobile: single column, conversation-first, panels as drawers.
- Desktop: conversation + collapsible side panel.
Dependencies
- fastify (v5.12.5 — high-performance, low-overhead HTTP framework with first-class SSE support)
- vite (v8.3.1 — fast dev server and build tooling for the frontend)
- tailwindcss (v4.3.3 — utility-first CSS framework for responsive styling)
- @fastify/static (v10.1.5 — serve the built frontend assets)
Testing Strategy
A mix of unit, integration, and end-to-end browser tests using Playwright.
- Unit tests (
tests/unit/web/): Verify each Fastify route handler in isolation — request validation (Zod schemas), SSE event framing, interrupt semantics, and the silent skill-execution path (assert no user message is added to session state). Mock the provider and session store so no network or disk I/O is required. - Integration tests (
tests/integration/web/): Start the Fastify server in-process, POST a chat message, assert the SSE stream emitsmessage/reasoning/tool events and the response is persisted to the session store. POST a skill execution and assert it runs without polluting the conversation context. Usenode:testwithassertto match the existing suite (tests/integration/api.test.js). - Playwright E2E tests (
tests/web/): Launch the built frontend against the running server and exercise the real UI in a browser:- Conversation view — send a message, assert streaming message bubbles, reasoning side-channel, and tool-call chips render.
- Slash command input —
/autocomplete, unknown/skillNameroutes to skill execution. - Skills panel — list renders, select invokes a skill.
- Memory / Sessions / Projects / Settings panels — each renders from its API contract.
- Token rate-limit indicator — reflects live
GET /api/tokensusage. - Responsive layout — assert the mobile single-column and desktop side-panel breakpoints.
- Edge cases: Empty message, unknown sessionId, unknown skill name, concurrent streams, interrupt mid-stream, large payloads, and browser-level failures (network drop mid-SSE, rapid interrupt).
Security Considerations
- Local-only binding: The server binds to
127.0.0.1by default (no auth, per requirements). Document that exposing the port to the network requires adding auth — the container runs asmadzwith filesystem write, process spawn, and network outbound. - Input validation: Validate all request bodies against Zod schemas before processing (per AGENTS.md 1.2).
- OWASP: No secrets in the web layer; all credentials remain in
process.env/config. URL allowlist for any outbound requests.
Alternatives Considered
- WebSocket — rejected: SSE is one-directional and maps 1:1 to the existing streaming callback; an explicit
POST /interruptcovers abort without a bidirectional dependency. - Express/Hono — rejected in favor of Fastify for its performance and first-class SSE support.
- Reusing the TUI's React components — rejected: the TUI uses Ink (terminal rendering), which is not browser-compatible; the frontend is a reimplementation of the same visual language.
- Chat-only API — rejected: a
/api/chat+/api/interrupt+/api/sessiontrio is a chat transport, not a 1:1 mirror of the TUI. It cannot replicate slash commands, panels, or silent skill invocation.
OpenSpec Note
This project uses OpenSpec for feature development. If this request is approved, I will:
- Run
/opsx:proposeto generate a full proposal with specs and tasks - Iterate on the design before any code is written
- Follow the task-driven implementation workflow
Additional Context
The engine is already headless — dispatchProvider(message, sessionState, streamingCallback, signal) is the exact entry point the TUI uses and emits structured streaming chunks. The web layer is a transport adapter over it. The container currently runs SSH + cron and the Ink TUI; there is no HTTP server in the codebase today.
Audit Findings (for Issue #1182)
index.js:303—dispatchProvider(message, _sessionState, streamingCallback, signal)is the exact entry point the TUI uses. It callscallProvider(line 177), which emits structured streaming chunks:{ type: "message", text },{ type: "reasoning", text },{ type: "on_tool_start", name, data: { input } },{ type: "on_tool_end", name, data: { output } }. This maps 1:1 onto SSE — no translation layer needed.index.js:431— exportsconfig,sessionState,dispatchProvider,handleConversation,scheduleManager,setConfigValue,loadContext,readMemoryFile,registry,tracer,handleShutdown. All the pieces a web server needs are already exported.index.js:330— mode branch:const mode = parsed.mode === "interactive" ? "interactive" : "chat";. Add awebmode here that reuses the already-initializedagent,sessionState, anddispatchProviderinstead of rendering the TUI. All subsystem init (config, telemetry, scheduler, skills, memory, GC, checkpointer) happens before this branch, so web mode is a second consumer, not a refactor.src/session/index.js— exportscreateSession,SessionStateManager,loadSession,saveSession,createCheckpointer,handleShutdown. Session persistence is already on disk and resumable; the web layer reuses it unchanged.src/config/loader.js— exportsloadConfig,setConfigValue,saveConfig. A newwebconfig section (host, port, static dir) needs a schema insrc/config/schemas/and aconfig.yamlentry.src/tui/commandHelp.js—COMMAND_GROUPSis the single source of truth for the banner and/help. The API surface must mirror every item here:/clear,/config set,/gc,/help,/memories,/new,/provider,/projects,/projects clear,/quit,/exit,/schedule,/sessions,/settings,/skills, plus skill invocation.src/tui/commandParser.js—CommandParserdispatches each command. The web layer should reuse the same dispatch logic (or a shared command module) so behavior is identical between TUI and web.src/tui/conversationArea.js:300-307— the/skillpath synthesizes"Run the <skill> skill"and dispatches viahandleChat(skillPrompt, { silentUser: true }). Critically,silentUseronly skips rendering the user message — at line 340 it still callssessionState.addExchange({ role: "user", content: text }), injecting the synthesized prompt into the context window. A true silent skill invocation must NOT add a user exchange.src/tui/conversationArea.js:591-700—createStreamingHandlershows the exact event shapes the web client must mirror:message,reasoning,on_tool_start,on_tool_end,tool_result,on_tool_error,on_chat_model_stream. The frontend reimplements this rendering (message bubbles, reasoning side-channel, tool call display) in the browser.src/tui/app.js:60— the TUI reads the token budget ceiling fromconfig.providers[providerName].rateLimit.maxTokensMinute. The SPA's token indicator needs the same ceiling plus live usage.src/provider/tokenBudget.js—createTokenBudgetexposescurrent()(rolling usage) andreserve()/reconcile()/release(). TheGET /api/tokensendpoint readsbudget.current()for live usage. The shared instance isgetSharedTokenBudget(maxTokensMinute)insrc/provider/openai.js:25.src/tui/settingsPanel.js— read-only config browser; flattens nested config into{ path, value }pairs. The SPA Settings view mirrors this.src/tui/skillsPanel.js—DESCRIPTION_MAX_LENGTH = 500; lists skills with name + description. The SPA Skills view mirrors this.src/tui/sessionsPanel.js— listsmemory/sessions/*.md, synthesizes a topic from the first user message. The SPA Sessions view mirrors this.src/tui/memoryPanel.js— browsesmemory/context/, filtering outephemeral-*.md,reflection.md,clarifications.md. The SPA Memory view mirrors this.src/tui/projectsPanel.js— lists directories underprojects/, excluding*.worktrees. The SPA Projects view mirrors this.src/skills/registry.js—SkillRegistryexposeslist(),get(name),getCatalog(),getSkillBody(name),getSkillPaths(). The silent skill-execution endpoint can resolve a skill by name and dispatch it without a context-window message.- No HTTP server exists — grep for
express|fastify|hono|createServer|ws|socket.io|SSEacrosssrc/andindex.jsreturns nothing. The container runs SSH + cron + the Ink TUI. The web layer is greenfield but sits on a stable engine. - No
web/directory exists — the frontend is greenfield. The Dockerfile copiessrc/,prompts/,.skills/but has no frontend build step or static asset copy. The Vite source lives inweb/, builds toweb/dist/, and is served by@fastify/static. config.yaml— nowebsection today; needsweb.host(default127.0.0.1),web.port, andweb.staticDir.
Fix Steps
- Add config schema — Create
src/config/schemas/web.jswithhost(default127.0.0.1),port(default e.g.3000),staticDir. Register it insrc/config/schemas/index.jsand add aweb:section toconfig.yaml. - Extract shared command dispatch — Refactor
CommandParser(or extract a shared command module) so both the TUI and web layer dispatch commands identically. This is the single source of truth for the 1:1 API surface. - Create the Fastify server — Add
src/web/server.jsexporting acreateWebServer({ config, sessionState, dispatchProvider, registry, scheduleManager })that registers the session-scoped endpoints (see API Surface):POST /api/sessions,GET /api/sessions,GET /api/sessions/:id,POST /api/sessions/:id/messages,POST /api/sessions/:id/interrupt, plus the global commands: config (GET/PATCH), gc, help, memories, provider, projects, projects/select, projects/clear, quit, schedule, settings, skills, skills/:name/execute, and tokens. - Implement SSE streaming — In
POST /api/sessions/:id/messages, load the session's conversation into the session state, calldispatchProvider(message, sessionState, streamingCallback, signal)and write each streaming chunk as an SSEdata:event. Mapmessage/reasoning/on_tool_start/on_tool_enddirectly. Persist the exchange viasessionState.addExchange+saveSessionafter the stream completes. Track in-flight streams per session soPOST /api/sessions/:id/interruptroutes to the correct AbortController. - Implement silent skill execution — Add
POST /api/skills/:name/executethat resolves the skill viaSkillRegistry, dispatches it WITHOUT callingsessionState.addExchange({ role: "user", ... }). This requires a new dispatch path (or asilentflag on the dispatch that skips the context-window injection, distinct from the TUI'ssilentUserwhich only skips rendering). - Add the
webmode branch — Inindex.js, addwebto the mode detection and start the server (reusing the already-builtagent,sessionState,dispatchProvider) instead of rendering the TUI. - Build the frontend — Scaffold
web/with Vite + Tailwind. Implement the six views (Conversation, Skills, Memory, Settings, Sessions, Projects) and the status bar per the SPA Specification. Use the defined data contracts for each panel endpoint. - Wire the Docker container — Add a frontend build step (Vite build →
web/dist/), copy the built assets into the image, expose the web port, adjustdocker-entrypoint.sh/CMD so the container can run--mode web, and document the port mapping. - Write tests — Add
tests/unit/web/server.test.js(route validation, SSE framing, interrupt semantics, silent skill execution) andtests/integration/web/server.test.js(POST chat → assert SSE events → assert session persisted; POST skill → assert no user message added to context). Runnpm run test,npm run lint,npm run coverage.
- Dominant language
- JavaScript
- Stars
- 2
- Forks
- 0
- Avg merge
- 1h 17m
- Merged PRs (30d)
- 242
Getting set up
- Ships a Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from avoidwork/madz
-
feat: add config-driven MCP server tool registrationPossibly taken @avoidwork claimed this today. Openapproved feature in progress
Difficulty 5/5 Over a week Newbie friendliness 12/100
Maintainers usually reply within 1 day
Similar issues
-
[BUG] Multi-day events show "Ended" while still in progressPossibly taken @tarunagnihotri534 claimed this today. Openbug
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
data-umbrella/du-event-board#231 · 2 comments ·
-
Difficulty 1/5 Under an hour Newbie friendliness 78/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
NaturalIntelligence/fast-xml-parser#888 · 1 comment ·
Maintainers usually reply within 2 days
-
Difficulty 1/5 Under an hour Newbie friendliness 80/100
USACE/chart-docs#766 ·
-
bug callouts regression revealjs
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
quarto-dev/quarto-cli#15014 ·
Maintainers usually reply within 1 day