@choonkeat/agent-chat
v0.18.0
Published
A "less intimidating" chat UI for any agent
Readme
Agent Chat
An MCP server that gives your AI agent a rich chat interface. Instead of reading raw tool calls in a terminal, your users get a rendered conversation with markdown, code blocks, quick reply buttons, and voice support.
This is the MCP that powers the Agent Chat tab in swe-swe.
Same conversation, two views. The terminal shows MCP tool calls and code diffs. Agent Chat renders the same content as a rich, interactive chat.
Features
- Rich markdown — messages render with full markdown, syntax-highlighted code blocks, and blockquotes
- File drag & drop — drop files into the chat to share them with the agent
- Images in messages — agents can include screenshots and images inline
- Voice conversation — speak to your agent and hear responses via text-to-speech
- Quick replies — agents can offer clickable response buttons for common actions
- Permission prompts in chat — when Claude Code is launched with
--dangerously-load-development-channels server:swe-swe-agent-chat, tool-use permission prompts are intercepted from stdin and surfaced as Allow/Deny quick replies in the chat UI (and spoken aloud in voice mode), instead of blocking on a TUI prompt
How it works
Agent Chat runs as an MCP server alongside your AI agent. The agent calls tools like send_message, send_progress, and check_messages to communicate with the user through a browser-based chat UI.
Agent (Claude, etc.)
│
├─ send_message("Here's what I found...") → Chat UI shows rich message
├─ send_progress("Working on it...") → Chat UI shows progress indicator
└─ check_messages() ← Chat UI returns user's replyMCP Tools
| Tool | Description |
|------|-------------|
| send_message | Send a message and wait for user response. Supports quick reply buttons. |
| send_verbal_reply | Send a spoken reply in voice mode (text-to-speech). |
| send_progress | Send a non-blocking progress update. |
| send_verbal_progress | Send a non-blocking spoken progress update. |
| check_messages | Non-blocking check for queued user messages. |
| set_chat_title | Name the streaming chat-log export (see below): renames the auto-written …-untitled.md to …-{slugified-title}.md and rewrites its header. Call again anytime to rename; also re-enables the export after chatlog_optout. |
| chatlog_close | Close out the streaming chat-log export for a clean git commit: freezes this session's .md (kept, unlike chatlog_optout), regenerates index.html, and returns the exact paths to git add (gitignored generated files left out). Requires a title while the file is still untitled; never renames an already-titled file. set_chat_title re-opens with a full-history backfill. |
| chatlog_optout | Stop the streaming chat-log export for this session and delete its .md (assets are left — content-sha names may be shared; index.html regenerated). |
| export_chat_md | Manually export the current chat as a markdown file (script-style **USER** / **AGENT** markers that render as iMessage-style left/right bubbles via a sibling index.html and as a normal markdown doc on GitHub/GitLab). Writes ./agent-chats/YYYY-MM-DD-NN-{title}.md, archives attachments to ./agent-chats/assets/ per AGENT_CHAT_EXPORT_ASSETS (or its assets param; default none = placeholders), refreshes viewer.css / viewer.js, and regenerates the chat-archive index.html. The manual escape hatch when the streaming export (below) is enabled. |
Chat commands
Typed by the user in the chat input, handled by the browser — these never reach the agent as messages. They need agent-chat to be running inside a host that owns the agent's terminal (swe-swe); standalone, they report that and do nothing.
| Command | Effect |
|---------|--------|
| stop (also wait, cancel, hold on, abort, halt, pause) | Interrupts the agent's current turn and nudges it to check_messages. |
| /clear-and-then <instruction> | Resets the agent's context, then hands it <instruction>. The message is recorded as typed — command and all — in the chat and the streaming chat log, so a log read later shows where the agent was reset; the agent itself is handed only <instruction>, from the message queue. It comes back with a line naming the log file as its context, so the conversation survives the reset even though the agent's own memory does not. A bare /clear resets with no follow-up instruction, and is recorded too. Requires the streaming export (below); without it the reset still happens but the earlier conversation is gone. See docs/adr/2026-08-02-clear-prefix-context-reset.md. |
| /compact-and-then <instruction> | Same sequence as /clear-and-then, but the agent summarises its context instead of losing it. Recorded the same way; the agent is woken with the ordinary nudge once the summary has had time to finish — there is no chat-log resume line, because the summary is the context. A bare /compact summarises with no follow-up instruction. |
| / at the start of a message | Offers the four commands above, ahead of whatever the / autocomplete provider (if any) returns. Elsewhere in a message / means whatever the provider says, or nothing. |
| clear context → yes | The older confirm-then-reset flow. Resets only; nothing is handed back. |
Streaming chat-log export
Set AGENT_CHAT_EXPORT_DIR (e.g. agent-chats, resolved relative to the
working directory — it cannot escape it) and the markdown archive writes
itself, no export_chat_md call needed:
Every chat bubble is appended to
{date}-{NN}-untitled.mdthe moment it happens ({date}-{NN}-untitled-{SESSION_UUID}.mdwhen aSESSION_UUIDenv var identifies the host session), and its attachments are handled at that same moment, while the upload files still exist.Attachments are NOT archived by default — screenshots grow a repo (or its git-LFS quota) fast.
AGENT_CHAT_EXPORT_ASSETSpicks what happens:none(default): images and binary files become[image.png #3]placeholders, numbered per chat; plain-text attachments (pasted long text) are still copied, since they are part of the conversation.small: PNG/JPEG images are downscaled to 1280px on the long edge and re-encoded (JPEG; PNG when transparent), only when that is smaller.original: every attachment copied byte-for-byte (the pre-0.15 behaviour).
Copied files go into the
assets/directory beside the.md(content-sha filenames).export_chat_mdfollows the same setting; itsassetsparameter overrides it for one export.A new chat is filed the way the archive already is. A chat can live flat in the archive root, or one directory per month:
agent-chats/ index.html ← landing page, always at the root assets/viewer.css, viewer.js ← one copy for the whole archive 2026-08-15-01-some-title.md ← flat assets/2026-08-15-01-1-{sha}.png 2026-08/ ← month layout 15-01-another-title.md assets/2026-08-15-01-2-{sha}.pngListing, session resume and daily
NNnumbering all read both shapes, so a mixed archive is correct. Where a new export goes follows the archive: flat until the archive holds at least one chat in a month directory, by month from then on. So one person migrating (below) and committing switches every copy that writes to that archive, with nothing to set per machine. Pin it with-chatlog-layout=flator=month(orAGENT_CHAT_CHATLOG_LAYOUT) — pinflatwhile anyone still runs a copy too old to see month directories: such a copy regeneratingindex.htmldrops every file it cannot see.agent-chat migrate-chatlogsfiles an existing flat archive by month — it prints thegit mvlines, and-applyruns them and regeneratesindex.html. Attachments keep their full-date basenames and are routed by what the markdown links to, so migrating rewrites no file content and a chat's./assets/…links resolve identically on GitHub and in the viewer.The agent names the file via
set_chat_title(renames + header rewrite; callable again to rename).chatlog_optoutstops the export for the session and deletes its.md.To commit the archive without it going dirty on the next reply, the agent calls
chatlog_close(optionally titling in the same call), which freezes the.mdand returns the exact paths togit add. Freezing loses nothing: the JSONL event log keeps recording, andset_chat_titlere-opens the export with a full-history rewrite that backfills anything that arrived while frozen.index.html— the archive landing page — is regenerated from the.mdfiles on disk, never patched incrementally. That makes it merge-friendly: on a git conflict inindex.html, accept either side (or delete the file); the next export regenerates it correctly. Duplicate dailyNNs from parallel branches are fine — the regenerated index lists both, and content-sha asset names never clobber.index.htmlis a tracked file, so it is only rewritten when the export set changes in a committable way:chatlog_close,chatlog_optout,export_chat_md, or aset_chat_titlethat renames an export already in the manifest. Streaming appends never touch it, and still-untitledexports are never listed — otherwise every live session would leave the working tree dirty with links to untracked files thatset_chat_titleis about to rename.The three generated files stay out of git in a new archive. When agent-chat creates the archive directory inside a git repo, it also writes
agent-chats/.gitignore:/index.html /assets/viewer.css /assets/viewer.jsNothing else under
assets/is generated — attachments cannot be rebuilt, so never ignore the directory.-chatlog-gitignore=off(orAGENT_CHAT_CHATLOG_GITIGNORE=off) keeps the file from being written, for a team that wants the bubble view committed: it then exists in a fresh clone and on a site published straight from the repo, which an ignored one does not.An ignored
index.htmlis regenerated every time agent-chat starts with the streaming export on (the viewer files always are), so chats agit pullbrought in are listed without anyone exporting. That runs in the background; the chat does not wait for it.chatlog_closeleaves ignored files out of the paths it returns, and includes the.gitignore.An archive that already exists is never switched — an ignore rule does nothing to files git already tracks, and a teammate on agent-chat ≤ 0.16.0 gets those three paths from
chatlog_close, whichgit addrefuses once they are ignored. To switch one deliberately, after everyone has updated: add the.gitignoreabove, thengit rm --cached agent-chats/index.html agent-chats/assets/viewer.css agent-chats/assets/viewer.js.The header comment carries a
session:line, so a restarted process (sameAGENT_CHAT_EVENT_LOG) resumes appending to its own file instead of minting a new one.Nothing is ever auto-committed — the export sits in the working tree.
Installation
Add as an MCP server to Claude Code:
claude mcp add agent-chat -- npx -y @choonkeat/agent-chatOr run standalone (HTTP-only mode):
npx -y @choonkeat/agent-chat --no-stdio-mcpThe chat UI opens automatically in your browser.
Environment variables
| Variable | Description |
|----------|-------------|
| AGENT_CHAT_PORT | Fixed port for the HTTP server (default: random) |
| AGENT_CHAT_EVENT_LOG | Path to a JSONL file for event persistence across restarts |
| AGENT_CHAT_EXPORT_DIR | Directory (relative to cwd) for the streaming markdown chat-log export; unset = disabled |
| AGENT_CHAT_EXPORT_ASSETS | What chat-log exports do with attachments: none (default — placeholders; text files still copied), small (images downscaled to 1280px), original (byte-for-byte) |
| AGENT_CHAT_CHATLOG_GITIGNORE | on (default) or off: whether a newly created chat-log archive inside a git repo gets a .gitignore for its three generated files. Same as -chatlog-gitignore |
| AGENT_CHAT_PROMPTS_FILE | Path to an editable copy of the text wrapped around each user message (prompts/agent-reply.tmpl). Missing or blank: filled with the built-in rules. Reread on every message; a broken file falls back to the built-in rules and says so in the chat |
| AGENT_CHAT_DISABLE | Set to any value to disable tools and HTTP server |
License
MIT
