Codumentor logo Codumentor

Web UI

Codumentor includes a protocol-native web UI built with React that provides a chat interface with real-time streaming, tool execution visualization, and plugin support.

Quick Start

Development Mode

Start the backend API server and the Vite dev server in separate terminals:

# Terminal 1: Start the API server (web UI is the default surface)
python -m codumentor.cli.main api --host 127.0.0.1 --port 2638

# Terminal 2: Start the Vite dev server (with hot reload)
cd ui
npm install   # First time only
npm run dev

Open http://localhost:5173 in your browser. The Vite dev server proxies API calls to the backend.

Production Mode

Build the UI and serve it from FastAPI:

# Build the UI
cd ui
npm run build

# Start the API server (UI is served at root by default)
python -m codumentor.cli.main api --host 127.0.0.1 --port 2638

Open http://127.0.0.1:2638/ in your browser.

Enabling the legacy OpenAI-compatible API alongside the web UI

By default the api command only serves the protocol-native /ui/ API and the React SPA. If you also need the legacy /v1/ OpenAI-compatible surface:

python -m codumentor.cli.main api --openai-api

Or set api.enable_openai_api: true in your codumentor.yaml. To suppress the web UI entirely (API-only deployment), combine with --no-web-ui.

Executable mode: The UI can also be served from the PyInstaller-built executable. Build the UI before running the build script so that ui/dist/ is bundled into the executable. See building.md for details.

URL Routing

The UI uses client-side routing with react-router for shareable conversation URLs and proper browser history navigation.

URL Structure

URLDescription
/Redirects to the last active conversation (from localStorage) or creates a new one
/loginLogin page (supports ?redirect= for post-login navigation)
/c/:conversationIdView/interact with a specific conversation
/c/:conversationId/t/:turnIdLink to a specific turn/message within a conversation

Features

Reverse Proxy Support

The UI supports running behind a reverse proxy with path rewriting. When the proxy sets the X-Forwarded-Prefix header, the server automatically:

  1. Injects a <base href="/prefix/"> tag into the HTML
  2. The React app and API client detect this and adjust their paths accordingly

Example nginx configuration:

location /codumentor/ {
    proxy_pass http://127.0.0.1:2638/;
    proxy_set_header X-Forwarded-Prefix /codumentor;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

This allows multiple Codumentor instances on different paths (e.g., /codumentor/, /project-a/, etc.).

Architecture

Protocol-Native Design

The web UI uses a custom protocol layer (/ui/ API endpoints) rather than the OpenAI-compatible /v1/ endpoints. This provides:

Component Stack

┌─────────────────────────────────────────┐
│  React App (ui/src/)                    │
│  - Chat transcript                      │
│  - Tool execution cards                 │
│  - Plugin event rendering               │
├─────────────────────────────────────────┤
│  UI Protocol API (/ui/*)                │
│  - Conversations (CRUD, SSE events)     │
│  - Extensions (plugin manifests)        │
├─────────────────────────────────────────┤
│  Event Store (SQLite)                   │
│  - Append-only event log                │
│  - Per-conversation sequence numbers    │
├─────────────────────────────────────────┤
│  Agent Runtime                          │
│  - Streaming responses                  │
│  - Tool execution                       │
│  - Plugin hooks                         │
└─────────────────────────────────────────┘

API Endpoints

Conversations

EndpointMethodDescription
/ui/conversationsGETList all conversations
/ui/conversationsPOSTCreate a new conversation
/ui/conversations/feedGETSSE list feed: list-relevant, slimmed events of every conversation in the caller's list (open only while the tab is visible)
/ui/conversations/{id}/snapshotGETGet materialized conversation state
/ui/conversations/{id}/eventsGETSSE stream of events
/ui/conversations/{id}/turnsPOSTStart a new turn (send message)
/ui/conversations/{id}/turns/{turn_id}/cancelPOSTCancel an in-progress turn
/ui/conversations/{id}/turns/{turn_id}/headlinesGETThe turn's last headlines, oldest first (?limit=1..5, default 5): {turn_id, headlines: [{text, provenance, agent}]} — the list row's hover trail. Owner only (404 unknown conversation, 403 someone else's); an unknown turn yields []
/ui/conversations/{id}DELETESoft-delete a conversation
/ui/conversations/{id}/restorePOSTRestore a deleted conversation

Extensions

EndpointMethodDescription
/ui/extensionsGETList available UI extensions
/ui/extensions/plugins/{plugin_id}/{path}GETServe plugin UI bundles

Event Types

Events are streamed via SSE and persisted in the event store. Each event has a seq (sequence number) for ordering and resume.

Turn Lifecycle

Assistant Messages

Tool Execution

Plugin Events

Snapshot Resume

The UI implements robust resume support:

  1. On page load, fetch /ui/conversations/{id}/snapshot to get current state
  2. Connect to SSE with ?after_seq={last_seq} to receive only new events
  3. If connection drops, reconnect with exponential backoff using last known seq

This ensures deterministic state reconstruction without replaying all events.

Development

Project Structure

ui/
├── src/
│   ├── components/     # React components
│   │   ├── MessageList.tsx
│   │   ├── ToolItem.tsx
│   │   ├── PluginItem.tsx
│   │   └── ExtensionsPage.tsx
│   ├── hooks/          # Custom React hooks
│   │   └── useExtensions.ts
│   ├── services/       # API and utilities
│   │   ├── api.ts
│   │   └── extensionLoader.ts
│   ├── types/          # TypeScript types
│   └── main.tsx        # Entry point
├── dist/               # Production build output
├── vite.config.ts      # Vite configuration
└── package.json

Vite Configuration

The Vite config handles both development and production:

// vite.config.ts
export default defineConfig(({ command }) => ({
  // Production uses /app base path (served by FastAPI)
  // Development uses root (Vite dev server)
  base: command === "build" ? "/app" : "/",
  server: {
    proxy: {
      "/ui": { target: "http://127.0.0.1:2638", changeOrigin: true },
      "/health": { target: "http://127.0.0.1:2638", changeOrigin: true },
      "/v1": { target: "http://127.0.0.1:2638", changeOrigin: true }
    }
  }
}));

Adding Components

  1. Create component in ui/src/components/
  2. Export from ui/src/components/index.ts
  3. Import and use in other components

Type Safety

Types are defined in two places:

Keep these in sync with backend Pydantic schemas in src/codumentor/api/ui/schemas.py.

Plugin UI Extensions

Plugins can provide custom UI components for rendering their events in the transcript.

Extension Discovery

The /ui/extensions endpoint returns manifests for all loaded plugins:

{
  "extensions": [
    {
      "plugin_id": "agentic_memory",
      "name": "Agentic Memory",
      "version": "1.0.0",
      "event_types": ["plugin.agentic_memory.stored", "plugin.agentic_memory.retrieved"],
      "ui": {
        "bundle_url": "/ui/extensions/plugins/agentic_memory/index.js",
        "sdk_version": "1.0",
        "custom_elements": ["x-agentic-memory-card"],
        "placements": ["transcript-card"]
      },
      "ui_enabled": true
    }
  ],
  "sdk_version": "1.0"
}

Plugin UI Bundles

Plugin UI bundles are ESM modules that register Custom Elements:

// ui/plugins/agentic_memory/index.js
class AgenticMemoryCard extends HTMLElement {
  connectedCallback() {
    const data = this.event?.data || {};
    this.innerHTML = `<div class="memory-card">Memory stored: ${data.summary}</div>`;
  }
}

customElements.define('x-agentic-memory-card', AgenticMemoryCard);

Place bundles in ui/plugins/<plugin_id>/index.js. They are served via /ui/extensions/plugins/<plugin_id>/....

Markdown code-block renderers

In addition to custom elements, extensions can register markdown fenced code-block renderers through ui.markdown_renderers in the extension manifest.

Each renderer declaration includes:

The host validates and registers these renderers after compatibility and origin checks pass. Invalid renderer registrations are logged and isolated to the offending extension.

Built-in renderer plugins:

Fallback Rendering

When a plugin has no UI bundle or is disabled, the PluginItem component renders a generic fallback card showing the event type and status.

Event Grouping

Plugin events can be grouped together using the event_group_id field. When multiple events share the same event_group_id, they are merged into a single transcript item, with the latest event updating the existing item. This is useful for plugins that emit events in phases (e.g., "started" → "completed").

How Event Grouping Works:

  1. First Event: When an event with event_group_id arrives, it creates a new transcript item
  2. Subsequent Events: Events with the same event_group_id update the existing item: - The event type, status, and timestamp are updated to the new values - Event data is merged (previous fields kept, new fields added/overwritten)

Explicit Status (Required):

Plugins must provide an explicit status field in their event data:

StatusMeaning
pendingOperation is in progress
successOperation completed successfully
errorOperation failed

If status is not provided, it defaults to "pending" and a warning is logged to help plugin developers catch the issue during development.

Example Event Flow:

// First event - creates transcript item
{
  type: "plugin.agentic_memory.storage_started",
  data: { status: "pending", event_group_id: "run-123", text_length: 1000 }
}

// Second event - updates the same transcript item
{
  type: "plugin.agentic_memory.stored",
  data: { status: "success", event_group_id: "run-123", stored_items: [...] }
}

The resulting transcript item will have:

Custom Element Properties:

Plugin custom elements receive these properties:

PropertyTypeDescription
eventPluginEventThe full plugin event object
eventTypestringCurrent event type (e.g., "plugin.agentic_memory.stored")
statusstringCurrent status: "pending", "success", or "error"
dataobjectMerged event data from all grouped events

Benefits of Event Grouping:

Troubleshooting

UI Not Loading

  1. Check if the UI is built: ls ui/dist/
  2. If empty, run: cd ui && npm run build
  3. Check server logs for "Serving UI from..." message

API Proxy Not Working (Dev Mode)

  1. Ensure backend is running on port 2638
  2. Check Vite proxy logs for connection errors
  3. Verify /ui/* routes respond: curl http://127.0.0.1:2638/ui/conversations

SSE Connection Issues

  1. Check browser DevTools Network tab for EventStream connection
  2. Verify after_seq parameter matches snapshot's last_seq
  3. Check for CORS errors if running UI on different origin

Extensions Not Loading

  1. Check /ui/extensions endpoint returns plugin manifests
  2. Verify ui_enabled: true in manifest
  3. Check browser console for extension loader errors

Attaching Files

When the Client Filesystem plugin is enabled, you can share local files with the AI agent directly from the chat interface.

Methods

MethodHow
Drag and dropDrag one or more files from your file manager and drop them anywhere on the chat window
Attach File buttonClick + → Attach File to open a file picker
Attach Local FolderClick + → Attach Local Folder to give the agent read/write access to an entire folder

Drag and Drop

Drag files onto the chat from your OS file manager. A "Drop files to attach" overlay confirms the target. On drop, each file is uploaded and appears as an attachment pill above the input box. The files are automatically included as context when you send your next message.

You can drop multiple files at once if your file manager supports multi-select drag.

Attachment Pills

Attached files appear as pills above the input area. Click × on a pill to remove an attachment before sending.

File Size Limits

Any file up to 50 MB can be attached. Files over the limit are rejected with an alert. Documents (.pdf, .docx, .xlsx) are extracted to text server-side before being sent to the agent; any other file (a zip, say) is staged as-is for the agent to inspect with a shell tool.

Browser support: The Attach Local Folder feature requires a Chromium-based browser (Chrome, Edge) and a secure context (HTTPS or localhost). Drag-and-drop and single-file upload work in all browsers.

Tool Approvals

When tools perform potentially dangerous operations (executing a shell command, reading a file outside the working directory, or calling an external system such as Gitea, Redmine, a database or Gmail), Codumentor prompts you for approval. File edits inside the working directory are not gated — no mode or rule prompts for them; the workspace boundary and your version control are what contain them. You can configure automatic approval rules to streamline your workflow.

Permission Dialog

When a tool requests permission, you'll see a dialog with several options:

ButtonDescription
DenyBlock this specific request
Allow OnceExecute this specific request
Allow All in this ConversationAuto-approve all similar requests for the current conversation
Create RuleSave rules, across all conversations, that let this request through (or block it)

Keyboard shortcuts: Press Enter to Allow Once, Esc to Deny.

Settings

Access Tool Approvals settings via the Settings dialog (gear icon in the header).

Auto-approve All Tools

Enable this toggle to skip all permission prompts. Warning: This allows tools to execute commands and reach external systems without confirmation. Deny rules still apply when this is enabled.

Permission Rules

Rules provide fine-grained control over which operations are automatically approved or blocked.

Rule evaluation order:

  1. Deny rules are checked first (highest priority)
  2. Allow rules are checked second
  3. If no rule matches, you're prompted for approval

Rule Syntax

Rules follow the pattern: ToolName or ToolName(specifier)

PatternDescription
ShellMatch all shell commands
Shell(npm run:*)Match commands starting with "npm run " (prefix match)
Read(.env)Match reading a specific file outside the working directory
Write(*.log)Reserved — nothing requests file-write approval yet

Wildcards

WildcardPositionBehaviorExample
:*End onlyPrefix match with word boundaryShell(npm:*) matches npm install but NOT npmx
*AnywhereGlob match (standard wildcard)Shell(git *) matches git status, git commit

Tool Names

Tool NamePermission TypeDescription
Shellshell_commandShell command execution
Writefile_writeReserved. No tool requests file-write approval today, so these rules match nothing
Readfile_readReading a file outside the working directory. A read inside it never reaches the gate

Examples

Common allow rules:

Shell(npm run:*)        # Allow npm run commands (test, build, etc.)
Shell(git status)       # Allow git status (exact match)
Shell(git diff:*)       # Allow git diff commands
Shell(python -m pytest:*)  # Allow pytest commands

Common deny rules:

Shell(rm -rf:*)         # Block recursive force delete
Shell(curl:*)           # Block curl commands
Read(.env)             # Block reading a .env outside the working directory
Read(.env.*)           # The same for .env.* files

Full example configuration:

{
  "allow": [
    "Shell(npm run:*)",
    "Shell(git status)",
    "Shell(git diff:*)",
    "Shell(python -m unittest:*)"
  ],
  "deny": [
    "Shell(rm -rf:*)",
    "Read(.env)",
    "Read(.env.*)"
  ]
}

Creating Rules from Permission Dialog

When a permission prompt appears, open Other ways to answer → Create Rule:

  1. Choose Allow or Block
  2. For Allow, pick an outcome: a set of rules the server generated and checked with the approval gate's own code — e.g. "echo and pwd, with any arguments" or "Exactly these commands" — or write your own under Custom… (one rule per line, checked as you type)
  3. Save: the rules are added to your record and the gate re-runs over the waiting request, which is answered by the rules if they cover it (the dialog closes), or stays with a note of which part is still uncovered

A command carrying an escalating badge can never be allowed by a rule; the editor says so instead
of offering a form. See the permissions guide
for the full behaviour, and doc/core-dev/design/permission-rule-dialog.md for the design.

Rule Persistence

Rules live on the server, on your own user record, and the server is what
evaluates them — the browser's localStorage holds a copy only so the settings
dialog can paint before the request comes back, and the server's answer
overwrites it. So your rules follow you to another browser or device, they
apply to every conversation, and a rule you cannot see in the dialog (an
operator's) still applies.

Two layers are evaluated, in this order:

  1. The operator's rules, from the server's config file. You cannot see or change these from the web UI; an operator deny is a floor nothing you set can lift.
  2. Your own rules, from this dialog.

Security Notes