lmchatkit

package module
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 12, 2026 License: MIT Imports: 21 Imported by: 0

README

LMChatKit

A self-contained chat UI + backend protocol that mounts into any Go HTTP server. The host implements the Host interface to provide an LLM completion stream and (optionally) MCP tools, prompts, and resources; lmchatkit owns the frontend bundle, the streaming chat protocol, persona loading, and slash-command loading.

Built for — and extracted from — paularlott/llmrouter, with the contract shaped so other OpenAI-compatible hosts (e.g. paularlott/knot) can mount the same UI without forking it.

Features

  • Streaming chat over a custom SSE protocol (delta / tool_call / done / error events). Not OpenAI-shaped — designed for chat UIs, so tool-call confirmation, per-tool disable, and "always allow this tool in this chat" all live in the frontend without server-side session state.
  • Persona loading from a watched TOML directory (system_prompt, default_model, [params] table). Hot-reloads on file change. A built-in Default persona is always offered even when the dir is empty.
  • Slash commands from a watched markdown directory. help.md/help. $ARGUMENTS in the body is spliced with whatever the user typed after the command.
  • MCP pass-through for tools, prompts, and resources via the Host interface — the host decides where they come from.
  • Tool-call confirmation flow with per-session auto-allow, persisted in browser sessionStorage. The server owns the tool list; the browser only handles approval (Allow / Always Allow / Deny).
  • Bundles its own HTML/CSS/JS — the host just calls Mount(mux). The frontend reuses the host's bundled Alpine + Tailwind; lmchatkit doesn't ship a copy.
  • Auth is host-owned — pass an AuthMiddleware in Config and it wraps every lmchatkit handler.

Install

go get github.com/paularlott/lmchatkit

Go 1.26+. The package has three direct dependencies: fsnotify/fsnotify, paularlott/cli, paularlott/logger.

Host contract

Implement Host:

type Host interface {
    // Models returns the models the chat user may select from.
    Models(ctx context.Context) ([]Model, error)

    // Complete streams a chat completion. Emit EventDelta / EventToolCall
    // values onto events, then return. If you emit tool calls, the frontend
    // will execute them via CallTool and resubmit the conversation.
    Complete(ctx context.Context, req CompleteRequest, events chan<- Event) error

    // MCP pass-through. May return nil/empty if the host has nothing to offer.
    ListTools(ctx context.Context) ([]Tool, error)
    CallTool(ctx context.Context, name string, arguments json.RawMessage) (ToolResult, error)
    ListPrompts(ctx context.Context) ([]Prompt, error)
    GetPrompt(ctx context.Context, name string, args map[string]string) (PromptResult, error)
    ListResources(ctx context.Context) ([]Resource, error)
    ReadResource(ctx context.Context, uri string) (ResourceResult, error)
}

Host must be safe for concurrent use — lmchatkit is stateless and one Server may serve many simultaneous sessions across many users.

Optional interfaces

Hosts may implement additional interfaces for extended behaviour:

Interface Method Effect
SystemPromptAugmenter AugmentSystemPrompt(ctx, current) string Dynamically augment the system prompt on every chat request (e.g. append a skill index). Transient — conversation history is not modified.

StandardHost implements SystemPromptAugmenter via a function field (nil = passthrough).

Persona & slash-command sources

Personas and slash commands each come from a pluggable source. Two builtin implementations cover the common cases; hosts with a different backing store (e.g. a database, or a single hard-coded system persona) supply their own.

type PersonaSource interface {
    Personas(ctx context.Context) ([]Persona, error)
}
type CommandSource interface {
    Commands(ctx context.Context) ([]SlashCommand, error)
}

The source is consulted on every request, so a DB-backed source always reflects the current row set without a watcher.

Source When to use
Config.PersonasDir (file) Multi-tenant hosts reading TOML from disk (llmrouter's default). Hot-reloads.
Config.CommandsDir (file) Same, for markdown-defined slash commands.
Config.PersonaSource Overrides PersonasDir. Implement yourself for a DB / API / single-system case.
Config.CommandSource Overrides CommandsDir. Per-user commands in knot, for example.
StaticPersonas One-liner for "exactly one system-defined persona".
StaticCommands Same for commands.

For knot's "one persona, one model, no per-user commands" case:

srv, _ := lmchatkit.New(lmchatkit.Config{
    Prefix: "/chat",
    Host:   knotHost,
    PersonaSource: lmchatkit.StaticPersonas{{
        ID: "knot", Name: "Knot", SystemPrompt: knotSystemPrompt, DefaultModel: "knot-1",
    }},
    // CommandsDir / CommandSource left nil — slash commands disabled.
    HostJSFile:  "/assets/knot.js",
    HostCSSFile: "/assets/knot.css",
})

When the host returns exactly one persona AND one model, the chat UI skips the persona/model picker entirely and drops the user straight into a conversation. (They can still hit "New Chat" to come back to the picker.)

Mount

import "github.com/paularlott/lmchatkit"

srv, err := lmchatkit.New(lmchatkit.Config{
    Prefix:       "/chat",
    PersonasDir:  "/etc/myapp/personas",
    CommandsDir:  "/etc/myapp/commands",
    Host:         myHost,
    AuthMiddleware: authMiddleware,  // wraps every handler; nil = no auth
    History:      historyStore,      // nil = browser sessionStorage fallback
    Events:       eventBroadcaster,  // nil = no SSE push (browser polls)
})

Conversation history (pluggable persistence)

By default (when Config.History is nil), conversations live in browser sessionStorage — ephemeral, per-tab, cleared on browser close. No server-side storage, no cross-tab sync.

When Config.History is set to a HistoryStore implementation, lmchatkit mounts conversation CRUD endpoints and the browser switches to server-side mode automatically:

Endpoint Method Purpose
{prefix}/api/conversations GET List all conversation summaries (no messages, ETag-cached)
{prefix}/api/conversations/{id} GET Full conversation with messages
{prefix}/api/conversations/{id} PUT Create or update
{prefix}/api/conversations/{id} DELETE Delete

The browser detects server-side mode on init by probing GET /api/conversations. If it returns 200, all conversation CRUD goes through the API. If 404 (HistoryStore not configured), it falls back to sessionStorage. No configuration needed on the browser side.

HistoryStore interface
type HistoryStore interface {
    List(ctx context.Context) ([]ConversationSummary, error)
    Get(ctx context.Context, id string) (*StoredConversation, error)
    Save(ctx context.Context, conv *StoredConversation) error
    Delete(ctx context.Context, id string) error
}

ConversationSummary is the lightweight sidebar entry (id, title, persona, model, timestamps). StoredConversation embeds it and adds Messages and EnabledTools. For per-user stores (knot), extract the user from the context.

llmrouter implementation

Uses snapshotkv (the same persistent store as MCP server config, but with a separate key prefix):

Data Key prefix Store
MCP server config mcp_servers: shared snapshotkv DB
Chat history chat_history: shared snapshotkv DB
OpenAI Responses conversations conversations: shared snapshotkv DB

All three share one snapshotkv database file but use distinct key prefixes, so they never collide. The FindKeysByPrefix scans are prefix-scoped. Memory-only mode (no --storage-path) falls back to an in-memory map — conversations work during the process but don't persist across restarts.

SSE event stream (cross-tab sync + push notifications)

When Config.Events is set to an *EventBroadcaster, lmchatkit mounts GET {prefix}/api/events — a single SSE endpoint per browser tab. The server pushes:

Event When Browser action
conversation_saved Any tab saves a conversation Refresh sidebar
conversation_deleted Any tab deletes a conversation Refresh sidebar, clear if current
conversation_renamed Any tab renames a conversation Refresh sidebar
prompts_changed Scriptling watcher detects file change ETag-fetch prompts
resources_changed Scriptling watcher detects file change ETag-fetch resources

This replaces polling — no fetch-on-done, no timers. Changes are pushed the instant they happen. The browser uses EventSource (native SSE client) which auto-reconnects on disconnect.


That registers:

| Route                              | Purpose                                            |
|------------------------------------|----------------------------------------------------|
| `GET /chat`                        | HTML shell (server-rendered; the rest is Alpine)   |
| `GET /chat/api/personas`           | Persona list (incl. built-in `Default`)            |
| `GET /chat/api/commands`           | Slash-command list (incl. rendered markdown body)  |
| `GET /chat/api/models`             | Models from `Host.Models`                          |
| `POST /chat/api/chat`              | Streaming completion (SSE response)                |
| `POST /chat/api/tools/call`        | Execute one tool (after user approval)             |
| `GET /chat/api/prompts`            | Prompts from `Host.ListPrompts`                    |
| `POST /chat/api/prompts/get`       | Render a prompt                                    |
| `GET /chat/api/resources`          | Resources (static + templates) from `Host`         |
| `POST /chat/api/resources/read`    | Read one resource                                  |
| `GET /chat/assets/*`               | Embedded `chat.js` bundle                          |

## Chat protocol

`POST /chat/api/chat` streams Server-Sent Events. Each event is one JSON-encoded [`Event`](types.go) prefixed with `data: ` and terminated by `\n\n`:

data: {"type":"delta","delta":"Hello"}

data: {"type":"reasoning","reasoning":"Let me think about this..."}

data: {"type":"tool_call","tool_call":{"id":"call_1","name":"search","arguments":{"q":"x"}}}

data: {"type":"done","finish_reason":"tool_calls"}


Event types: `delta` (visible text), `reasoning` (thinking/reasoning text, rendered in a collapsible Thinking disclosure), `tool_call` (model requests a tool), `done` (stream complete), `error`.

The frontend reads with `fetch` + `ReadableStream` (not `EventSource` — `EventSource` can't POST). On `tool_call`, the UI prompts the user; on approve it `POST /api/tools/call`s the tool, appends the result to the conversation, and re-`POST /api/chat`s to continue. On `done` or `error` the turn finalizes.

### Tool management

The server owns the tool list entirely. On each `/api/chat` request, the server calls `Host.ListTools` to get available tools, injects the virtual `lmchatkit__get_skill` tool if skills exist, and forwards everything to the LLM. The browser never sees the tool list — it only handles the approval flow (Allow / Always Allow / Deny) when tool calls arrive as SSE events. Tool enable/disable is managed at the MCP server level (e.g. via the admin UI), not per-conversation in the browser.

This protocol is intentionally **not** OpenAI-shaped — the host's `Complete` implementation is free to call OpenAI, Anthropic, a local llama.cpp, or anything else. The reference implementation in `llmrouter` does a loopback HTTP call to its own `/v1/chat/completions` and translates OpenAI's SSE format into lmchatkit events (including `delta.reasoning_content` / `delta.reasoning` → `EventReasoning`).

The chat request body is minimal — `{model, persona_id, messages, params}`. The server derives the system prompt from the persona (looked up by `persona_id` in the in-memory persona cache), builds the tool list from `Host.ListTools`, and injects virtual tools (`lmchatkit__get_skill`). The browser never sends system messages or tool definitions.

## Personas

One TOML file per persona. The filename stem becomes a stable identifier; the `name` field is the display name. See `examples/personas/` for ready-to-use examples.

```toml
# examples/personas/coder.toml
name = "Code Assistant"
description = "Helps with code review, writing tests, and debugging."
system_prompt = """You are a senior software engineer with decades of experience.
Always prefer readable, maintainable code over clever one-liners.
When suggesting changes, explain WHY, not just WHAT."""

[params]
temperature     = 0.2     # randomness (0 = deterministic, 1 = creative)
top_p          = 0.95    # nucleus sampling threshold
top_k          = 40      # top-k sampling (llama.cpp / Ollama)
repeat_penalty  = 1.1     # penalise repeated tokens (llama.cpp / Ollama)
context_length  = 8192    # max context window in tokens

All fields are optional except name (which falls back to the filename stem if omitted). default_model is also optional — when omitted, the user picks a model from the picker. [params] is a free-form map merged into every completion request — standard OpenAI params (temperature, top_p, max_tokens, frequency_penalty, presence_penalty) and llama.cpp/Ollama params (top_k, repeat_penalty, context_length) are all forwarded to the host, which passes them through to the underlying API.

The directory is watched with fsnotify; adding or editing a persona takes effect on the next request without restarting the server.

When no PersonasDir is configured, the chat still works — a single built-in Default persona with no system prompt is offered.

Slash commands

One Markdown file per command. The filename (minus .md) is the command name, lowercased. Use $ARGUMENTS in the body to splice whatever the user typed after the command. See examples/commands/ for ready-to-use examples.

Commands support optional YAML frontmatter at the top of the file (Claude CLI style):

<!-- examples/commands/review.md -->
---
description: Review code and suggest improvements
argument-hint: <paste-code-or-describe-changes>
---

Review the following code or changes. Focus on:

1. **Correctness** — bugs, edge cases, logic errors
2. **Readability** — naming, structure, comments

Code or description to review:

$ARGUMENTS

Frontmatter fields:

  • description — shown in the slash command dropdown
  • argument-hint — placeholder hint shown after the command name (e.g. <github-handle>)
  • allowed-tools — comma-separated tool names to auto-approve for this session when the command runs (Claude CLI style; patterns like Bash(git:*) are stripped to just the tool name)

Rules:

  • Filenames beginning with _ are drafts (skipped).
  • Names are restricted to letters, digits, -, _ (so they stay unambiguous in chat input and shell-safe).
  • /Help and /help both resolve — the name is lowercased before lookup.
  • The directory is watched; commands hot-reload.

When no CommandsDir is configured, slash commands are disabled entirely. Typing /anything is then sent to the model as a literal user message.

MCP prompts (slash commands from the MCP server)

MCP prompts merge into the same / namespace as file-based slash commands. The user invokes them identically — there's no separate /prompt prefix. File commands take precedence on name collision.

How it works

When the user types /explain concept=recursion level=simple:

  1. The frontend checks file-based commands first — no match for explain
  2. Falls through to MCP prompts — finds explain with declared args concept (required) and level (optional)
  3. Parses arguments: key=value pairs, or positional shorthand for single-arg prompts (/summarise some text{text: "some text"})
  4. Calls POST {prefix}/api/prompts/get with the name and parsed args
  5. The host's GetPrompt renders the prompt and returns messages
  6. Those messages are injected into the conversation and a completion turn starts
Autocomplete

Typing / shows a unified dropdown of file commands and MCP prompts. MCP prompts are marked with a purple bolt icon and show their argument names (e.g. concept* level) in the hint column. Arrow keys navigate, Enter/Tab selects.

Discovery
  • /list-prompts — renders an info card listing all available MCP prompts with their argument signatures
  • /list-resources — renders an info card listing all available MCP resources with their URIs
Prompt arguments

Arguments are parsed from the text after the command name:

/explain concept=recursion level=advanced
/summarise this is a long block of text that goes to the single "text" arg
/review code="print('hello')"

If the prompt declares exactly one argument and the input has no = signs, the entire remainder is assigned to that argument positionally. Otherwise, key=value pairs are extracted.

MCP resources (attach context with @)

MCP resources are data the user can attach to a message as context for the model. They appear as attachment chips (like email attachments) on the user's message bubble — the raw content is hidden from the transcript but sent to the model as prefixed context.

How it works
  1. User types @ in the composer — a green dropdown shows matching resource URIs
  2. Selecting a resource reads it immediately via POST {prefix}/api/resources/read
  3. The content is added to a pending-attachments tray below the textarea (green chips with remove buttons)
  4. On send, each attachment's content is prepended to the user's message text:
Resource docs://readme.md:
<full resource content here>

<user's actual message text>
  1. The model sees the full resource text; the transcript shows only the chip.
Resource templates

Template resources (with {var} placeholders in the URI) appear in the dropdown marked as "template". The user types the variable part into the URI — e.g. selecting greeting://{name} and the frontend reads greeting://Ada (the @ autocomplete matches on URI substring, so typing @greet or @Ada finds it).

Multiple attachments

Multiple @resource selections can be attached to a single message. Each is rendered as a separate chip and its content is injected separately.

Skills (lazy-loaded context via virtual tool)

Skills are resources with a skill:// URI prefix that the model can retrieve on demand. Unlike @resource attachments (which the user manually adds), skills are model-driven — the LLM decides it needs a skill and calls a tool to get it.

How it works
  1. On every /api/chat and /api/tools request, lmchatkit checks the host for skill:// resources
  2. If any exist, a virtual tool lmchatkit__get_skill is appended to the tool list
  3. The host's SystemPromptAugmenter (if implemented) appends skill names + descriptions to the system prompt
  4. The model calls lmchatkit__get_skill with a skill URI (e.g. skill://golang)
  5. lmchatkit intercepts the call and routes it to Host.ReadResource — never touches the MCP server
  6. The skill content is returned as a tool result; the model reads it and continues

The tool is auto-approved by the browser (added to autoAllowTools on init) — skill loading is invisible to the user, no approval prompt.

Why lmchatkit__get_skill?

The lmchatkit__ prefix prevents collisions: local scriptling tools have no prefix, remote MCP tools get their namespace__ prefix. The tool is conditional — only appears when skill:// resources exist.

SystemPromptAugmenter (optional host interface)

Hosts can implement this to dynamically augment the system prompt on every chat request:

type SystemPromptAugmenter interface {
    AugmentSystemPrompt(ctx context.Context, current string) string
}

StandardHost implements it via a SystemPromptAugmenter function field (nil = passthrough). The returned string replaces the system prompt sent to the LLM. The stored conversation is not modified — augmentation is transient, recomputed on each request.

StandardHost also exposes the function directly:

host := &lmchatkit.StandardHost{
    SystemPromptAugmenter: func(ctx context.Context, current string) string {
        return current + "\n\n" + buildSkillIndex(ctx)
    },
    // ...
}
Skill sources

Skills work from any resource source because the virtual tool calls the standard Host.ReadResource — files (scriptling), remote MCP servers, databases, or custom providers. The source is transparent.

Frontend

The chat UI lives in web/:

  • web/src/chat.js — Alpine data component (includes the markdown processor). Conversations persist to sessionStorage. The frontend does not bundle its own Alpine or Tailwind — it loads them from the host's bundled assets. This keeps lmchatkit free of CDN dependencies and version skew.
  • examples/chat.html — reference template that hosts copy into their own template tree (where Tailwind can scan it during build).
Script-order gotcha

The host's bundle calls Alpine.start() synchronously at the bottom. start() immediately dispatches alpine:init — the only window in which Alpine.data(...) registrations are accepted. So chat.js must run BEFORE the host bundle. The shipped example template orders the two <script defer> tags accordingly; if you customise the template, preserve that order.

Auth

Auth is entirely the host's responsibility. lmchatkit takes an AuthMiddleware func(http.Handler) http.Handler in Config and wraps every handler with it. The host decides what auth means — session cookie, bearer token, mTLS, IP allow-list, anything. nil means no auth (rare; appropriate only for fully internal hosts).

The host's template provides whatever login/logout UI it wants — lmchatkit's JS has no knowledge of auth endpoints.

Testing

go test ./...

Coverage focuses on the protocol handlers (chat streaming, tool filtering, tool execution, prompt rendering, resource reading), the persona and command loaders (TOML parsing, hot reload, edge cases), and the SSE translator. The frontend is not unit-tested; verify changes manually.

License

See LICENSE.txt.

Documentation

Overview

Package lmchatkit is a self-contained chat UI + backend protocol that mounts into any HTTP server. The host implements Host to provide an LLM completion stream and (optionally) MCP tools, prompts and resources; lmchatkit owns the frontend bundle, the chat session protocol, persona loading and slash-command loading.

Routes are mounted under a configurable prefix (typically /chat) via Server.Mount. Auth is the host's responsibility — pass an [AuthMiddleware] in Config and it wraps every lmchatkit handler.

Index

Constants

View Source
const SkillToolName = "lmchatkit__get_skill"

SkillToolName is the name of the virtual skill-retrieval tool. The lmchatkit__ prefix prevents collisions with local scriptling tools (no prefix) and remote MCP tools (namespace__ prefix).

Variables

View Source
var AssetFS = assetsFS

AssetFS exposes the bundled chat.js so hosts can serve it from their own asset pipeline if they prefer. Server.Mount already wires it up at {prefix}/assets/chat.js.

View Source
var ErrMissingHost = errString("lmchatkit: Config.Host is required")

ErrMissingHost is returned by New when Config.Host is nil.

View Source
var SkillTool = Tool{
	Name:        SkillToolName,
	Description: "Retrieve a skill's detailed instructions by URI. Pass the skill URI (e.g. 'skill://golang' or '@skill://golang'). Returns the skill content as text.",
	InputSchema: map[string]interface{}{
		"type": "object",
		"properties": map[string]interface{}{
			"name": map[string]interface{}{
				"type":        "string",
				"description": "The skill URI to retrieve (e.g. skill://golang)",
			},
		},
		"required": []string{"name"},
	},
}

SkillTool is the virtual tool definition appended to the tool list when the host has skill:// resources. It lets the LLM pull in skill instructions on demand — the model sees skill descriptions in the system prompt, then calls this tool to retrieve the full content.

Functions

func OpenAIChatRequest

func OpenAIChatRequest(req CompleteRequest) map[string]interface{}

use this instead of hand-rolling the conversion. The returned map is ready to json.Marshal and POST.

func TranslateOpenAIStream

func TranslateOpenAIStream(ctx context.Context, body io.Reader, events chan<- Event) error

TranslateOpenAIStream reads an OpenAI-compatible SSE stream (the response body from POST /v1/chat/completions with stream:true) and emits lmchatkit events onto the events channel. Handles:

  • delta.content → EventDelta
  • delta.reasoning_content / delta.reasoning → EventReasoning
  • delta.tool_calls (fragmented by index) → accumulated and flushed as EventToolCall before the terminal event
  • finish_reason → mapped to FinishStop / FinishToolCalls / FinishLength
  • data: [DONE] → stream end

Hosts that loopback to their own OpenAI endpoint can call this directly instead of reimplementing the SSE parsing. The events channel must be buffered (lmchatkit's chat handler uses a 32-slot buffer).

Types

type CommandSource

type CommandSource interface {
	Commands(ctx context.Context) ([]SlashCommand, error)
}

CommandSource is the backend behind /api/commands. Same contract as PersonaSource: a file-watching default exists, hosts with a database (e.g. per-user commands in knot) implement their own.

type CompleteRequest

type CompleteRequest struct {
	Model    string                 `json:"model"`
	Messages []Message              `json:"messages"`
	Tools    []Tool                 `json:"tools,omitempty"`
	Params   map[string]interface{} `json:"params,omitempty"`
}

CompleteRequest is the host-facing request to stream a chat completion. Messages is the full conversation including any prior tool results. Tools is the subset of tools the user has enabled for this chat (may be empty). Params is model parameters merged from persona + per-request overrides (temperature, max_tokens, etc.); the host passes it through to the underlying LLM API as it sees fit.

type Config

type Config struct {
	// Prefix is the URL prefix lmchatkit mounts under, e.g. "/chat". Must be
	// non-empty. Routes are registered as Prefix+"/api/..." and
	// Prefix+"/assets/...". The host owns the Prefix page route itself —
	// see lmchatkit/examples/chat.html.
	Prefix string

	// PersonasDir is the directory scanned for persona TOML files. Empty
	// means no persona files — only the built-in Default persona is offered.
	//
	// Ignored when PersonaSource is set; use whichever fits the host. File
	// watching only applies to this dir, not to a custom source.
	PersonasDir string

	// CommandsDir is the directory scanned for slash-command markdown files.
	// Empty disables slash commands entirely. Ignored when CommandSource is
	// set.
	CommandsDir string

	// PersonaSource overrides PersonasDir. Use this when personas come from
	// somewhere other than the filesystem — typically a database, or a single
	// system-defined persona via [StaticPersonas].
	PersonaSource PersonaSource

	// CommandSource overrides CommandsDir. Same contract as PersonaSource.
	CommandSource CommandSource

	// Host is the contract between lmchatkit and the embedding application.
	// Must be non-nil.
	Host Host

	// AuthMiddleware wraps every lmchatkit HTTP handler. It is the host's
	// responsibility to enforce authentication, sessions, rate limiting, etc.
	// nil means no auth (rare; only appropriate for fully internal hosts).
	AuthMiddleware func(http.Handler) http.Handler

	// History persists chat conversations server-side. nil = browser
	// sessionStorage (no persistence across browser restarts, no
	// cross-tab sync). When non-nil, conversation CRUD endpoints are
	// mounted and the browser switches to server-side mode automatically.
	History HistoryStore

	// Events broadcasts changes to connected SSE clients for cross-tab
	// sync and push notifications (tools/prompts/resources changed).
	// nil = no SSE (browser falls back to polling on chat completion).
	Events *EventBroadcaster
}

Config configures a Server at mount time.

type ConversationSummary

type ConversationSummary struct {
	ID        string                 `json:"id"`
	Title     string                 `json:"title"`
	PersonaID string                 `json:"persona_id"`
	Model     string                 `json:"model"`
	Params    map[string]interface{} `json:"params,omitempty"`
	CreatedAt int64                  `json:"created_at"`
	UpdatedAt int64                  `json:"updated_at"`
}

ConversationSummary is the lightweight sidebar entry — no messages.

type Event

type Event struct {
	Type         EventType    `json:"type"`
	Delta        string       `json:"delta,omitempty"`
	Reasoning    string       `json:"reasoning,omitempty"` // carries EventReasoning fragments
	ToolCall     *ToolCall    `json:"tool_call,omitempty"`
	FinishReason FinishReason `json:"finish_reason,omitempty"`
	Usage        *Usage       `json:"usage,omitempty"`
	Error        string       `json:"error,omitempty"`
}

Event is one streamed server-sent event in the chat protocol. Type determines which fields are meaningful.

type EventBroadcaster

type EventBroadcaster struct {
	// contains filtered or unexported fields
}

EventBroadcaster fans out events to all connected SSE subscribers. Used for cross-tab sync (conversation saved/deleted from another tab) and push notifications (tools/prompts/resources changed by the scriptling watcher).

func NewEventBroadcaster

func NewEventBroadcaster() *EventBroadcaster

NewEventBroadcaster creates a ready-to-use broadcaster.

func (*EventBroadcaster) Broadcast

func (b *EventBroadcaster) Broadcast(event ServerEvent)

Broadcast sends an event to all subscribers. Non-blocking — if a subscriber's buffer is full, the event is dropped.

func (*EventBroadcaster) Subscribe

func (b *EventBroadcaster) Subscribe() chan ServerEvent

Subscribe returns a channel that receives events. The caller must Unsubscribe when done (e.g. when the SSE connection closes).

func (*EventBroadcaster) Unsubscribe

func (b *EventBroadcaster) Unsubscribe(ch chan ServerEvent)

Unsubscribe removes a subscriber and closes its channel.

type EventType

type EventType string

EventType identifies one SSE event in the chat stream protocol.

const (
	EventDelta     EventType = "delta"     // partial assistant text
	EventReasoning EventType = "reasoning" // partial reasoning/thinking text (separate from visible content)
	EventToolCall  EventType = "tool_call" // model requested a tool call; frontend must confirm + execute then resubmit
	EventDone      EventType = "done"      // stream complete; carry usage/finish_reason
	EventError     EventType = "error"     // stream failed
)

type FinishReason

type FinishReason string

FinishReason explains why the stream ended.

const (
	FinishStop      FinishReason = "stop"
	FinishToolCalls FinishReason = "tool_calls"
	FinishLength    FinishReason = "length"
)

type HistoryStore

type HistoryStore interface {
	List(ctx context.Context) ([]ConversationSummary, error)
	Get(ctx context.Context, id string) (*StoredConversation, error)
	Save(ctx context.Context, conv *StoredConversation) error
	Delete(ctx context.Context, id string) error
}

HistoryStore persists chat conversations server-side. If nil on Config, the browser uses sessionStorage (no server-side persistence, no cross-tab sync). Hosts implement this with KV stores, databases, files — whatever fits. For per-user stores (knot), the implementation extracts the user from the request context; lmchatkit passes ctx through unchanged, so the host owns both the identity context key (set in its auth middleware) and its interpretation here.

type Host

type Host interface {
	// Models returns the models the chat user may select from. The list may
	// be empty if the host has no concept of model picker (rare).
	Models(ctx context.Context) ([]Model, error)

	// Complete streams a chat completion for the given request. Implementations
	// push events onto events (never block on a full channel — lmchatkit's
	// channel is buffered) and return when the stream is finished. If the
	// model emitted tool calls, emit one [EventToolCall] per call and return
	// with FinishReason == FinishToolCalls — the frontend will execute the
	// tools via CallTool and resubmit the conversation.
	Complete(ctx context.Context, req CompleteRequest, events chan<- Event) error

	// ListTools returns the MCP tools available to chat. May return nil/empty
	// if no tools are configured.
	ListTools(ctx context.Context) ([]Tool, error)

	// CallTool invokes a tool by name with raw-JSON arguments. The arguments
	// are exactly what the model produced (after the user confirmed), so the
	// host is responsible for any validation.
	CallTool(ctx context.Context, name string, arguments json.RawMessage) (ToolResult, error)

	// ListPrompts / GetPrompt expose MCP prompts. GetPrompt renders the prompt
	// with the given arguments.
	ListPrompts(ctx context.Context) ([]Prompt, error)
	GetPrompt(ctx context.Context, name string, args map[string]string) (PromptResult, error)

	// ListResources / ReadResource expose MCP resources. ReadResource takes a
	// concrete URI (the caller is responsible for expanding templates).
	ListResources(ctx context.Context) ([]Resource, error)
	ReadResource(ctx context.Context, uri string) (ResourceResult, error)
}

Host is the contract between lmchatkit and its embedding application. Every method takes a context so hosts can enforce timeouts / cancellation. Any method may return an error; lmchatkit surfaces it to the user.

All methods must be safe for concurrent use: lmchatkit is stateless and a single Server may serve many simultaneous chat sessions across many users.

type Message

type Message struct {
	ID         string                 `json:"id,omitempty"`
	Role       Role                   `json:"role"`
	Content    any                    `json:"content"`
	Thinking   string                 `json:"thinking,omitempty"`
	Info       map[string]interface{} `json:"info,omitempty"`
	ToolCalls  []ToolCall             `json:"tool_calls,omitempty"`
	ToolCallID string                 `json:"tool_call_id,omitempty"`
	ToolName   string                 `json:"tool_name,omitempty"`
}

Message is one turn in a conversation. Content is normally a string, but when a message carries resource attachments the frontend may send it as an OpenAI-compatible content array. The Go type uses interface{} to accept both shapes and pass them through to the host's Complete implementation verbatim.

ID, Thinking, and Info are UI-only fields that the frontend sets on messages for display purposes (Alpine x-for keys, reasoning disclosure, info cards). They are persisted in conversation history so the UI can reconstruct the exact display on reload, but the chat handler strips them before passing messages to Host.Complete — the LLM never sees them.

Content does NOT use omitempty — empty string content must be preserved when saving/loading conversations from the history store. With omitempty, an empty assistant message would lose its "content" key entirely, and on reload the browser would see `undefined` instead of `""`.

type Model

type Model struct {
	ID       string `json:"id"`
	Label    string `json:"label,omitempty"`    // human-friendly label; falls back to ID
	Provider string `json:"provider,omitempty"` // optional source tag for the UI
}

Model describes one model the chat user can pick from.

type Persona

type Persona struct {
	ID           string                 `json:"id"`
	Name         string                 `json:"name"`
	Description  string                 `json:"description,omitempty"`
	SystemPrompt string                 `json:"system_prompt,omitempty"`
	DefaultModel string                 `json:"default_model,omitempty"`
	Params       map[string]interface{} `json:"params,omitempty"`

	// SourceFile is absolute path the persona was loaded from. Empty for the
	// built-in "Default" persona.
	SourceFile string `json:"-"`
}

Persona is one chat persona loaded from a TOML file in PersonasDir. SystemPrompt, DefaultModel and Params are all optional; an empty persona is valid and reduces to a no-op preset.

type PersonaSource

type PersonaSource interface {
	Personas(ctx context.Context) ([]Persona, error)
}

PersonaSource is the backend behind /api/personas. The default implementation reads TOML files from a watched directory; hosts with a database (or a single system-defined persona) supply their own.

Personas is called on every /api/personas request so a DB-backed source always reflects current state without needing a watcher.

type Prompt

type Prompt struct {
	Name        string           `json:"name"`
	Description string           `json:"description,omitempty"`
	Arguments   []PromptArgument `json:"arguments,omitempty"`
}

Prompt is one MCP prompt exposed to the chat.

type PromptArgument

type PromptArgument struct {
	Name        string `json:"name"`
	Description string `json:"description,omitempty"`
	Required    bool   `json:"required,omitempty"`
}

PromptArgument is one named argument a prompt accepts.

type PromptMessage

type PromptMessage struct {
	Role    Role   `json:"role"`
	Content string `json:"content"`
}

PromptMessage is one message produced by rendering a prompt.

type PromptResult

type PromptResult struct {
	Description string          `json:"description,omitempty"`
	Messages    []PromptMessage `json:"messages"`
}

PromptResult is the rendered output of GetPrompt.

type Resource

type Resource struct {
	URI         string `json:"uri"`
	Template    bool   `json:"template,omitempty"`
	Name        string `json:"name,omitempty"`
	Description string `json:"description,omitempty"`
	MimeType    string `json:"mime_type,omitempty"`
}

Resource is one MCP resource (static or templated) exposed to the chat. When Template is true, URI contains {var} placeholders the user must fill.

type ResourceResult

type ResourceResult struct {
	URI      string `json:"uri"`
	Text     string `json:"text,omitempty"`
	Blob     string `json:"blob,omitempty"`
	MimeType string `json:"mime_type,omitempty"`
}

ResourceResult is the content of a read resource. Text is used for textual content; Blob carries base64-encoded binary content.

type Role

type Role string

Role identifies the speaker of a chat message. Mirrors OpenAI's role names so hosts that proxy to OpenAI-compatible APIs can pass messages through verbatim.

const (
	RoleSystem    Role = "system"
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
	RoleTool      Role = "tool"
)

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is a self-contained chat UI + backend. Build one with New and mount it into any *http.ServeMux via Server.Mount.

func New

func New(cfg Config) (*Server, error)

New builds a Server, eagerly loading personas and slash commands from the configured sources so the first request is fast.

func (*Server) Close

func (s *Server) Close()

Close releases watchers and goroutines owned by this Server. Sources the host supplied via Config.PersonaSource / Config.CommandSource are NOT closed — the host owns their lifecycle. Safe to call multiple times.

func (*Server) Mount

func (s *Server) Mount(mux *http.ServeMux)

Mount registers lmchatkit's API and asset routes on mux under the configured prefix. It deliberately does NOT register a page route — the host owns the chat page template (so it lives in the host's source tree where Tailwind can scan it). Hosts render /chat themselves using the example template shipped at lmchatkit/examples/chat.html as a starting point.

Routes registered:

  • {prefix}/api/... — chat/tools/prompts/resources endpoints
  • {prefix}/assets/... — embedded chat.js, chat.css, markdown.js

The host's AuthMiddleware (if set) wraps every handler.

type ServerEvent

type ServerEvent struct {
	Type string `json:"type"` // conversation_saved, conversation_deleted, conversation_renamed, prompts_changed, resources_changed
	ID   string `json:"id,omitempty"`
}

ServerEvent is one event pushed to SSE subscribers.

type SlashCommand

type SlashCommand struct {
	ID           string `json:"id"`                      // stable hash of name
	Name         string `json:"name"`                    // canonical name (filename stem), no leading slash
	Description  string `json:"description,omitempty"`   // from frontmatter, shown in the selection menu
	ArgumentHint string `json:"argument_hint,omitempty"` // from frontmatter, e.g. "<github-handle>"
	AllowedTools string `json:"allowed_tools,omitempty"` // from frontmatter, comma-separated tool names to auto-allow
	Body         string `json:"body"`                    // raw markdown (frontmatter stripped); $ARGUMENTS replaced when rendered
	Source       string `json:"source"`                  // short source hint for debugging
}

SlashCommand is one user-invokable slash command loaded from a markdown file in CommandsDir. The command name is the filename stem (e.g. help.md -> /help). The Body is the raw markdown with optional $ARGUMENTS placeholder substituted at render time. Description and ArgumentHint come from YAML frontmatter at the top of the file (Claude-style):

---
description: Open a PR and tag a reviewer
argument-hint: <github-handle>
---

Review the changes by $ARGUMENTS and suggest...

type StandardHost

type StandardHost struct {
	// ModelsFunc returns the models available for selection. Called on each
	// /api/models request so the list is always current. Required.
	ModelsFunc func(ctx context.Context) ([]Model, error)

	// OpenAIBaseURL is where /v1/chat/completions lives. For a self-loopback
	// (llmrouter), this is "http://127.0.0.1:<port>". For an external LLM
	// proxy, it's that proxy's URL. Required.
	OpenAIBaseURL string

	// OpenAIToken is the bearer token sent with the completion request.
	// For a self-loopback this is the server's API token; for a user-scoped
	// proxy it's the user's token. May be empty if the endpoint doesn't
	// require auth.
	OpenAIToken string

	// MCPServer returns the *mcp.Server to use for tool/prompt/resource
	// calls. For single-user hosts (llmrouter), return the same server
	// every time. For per-user hosts (knot), extract the user from the
	// context and return their server. Return nil to disable MCP for that
	// request.
	MCPServer func(ctx context.Context) *mcplib.Server

	// SystemPromptAugmenter is called on every /api/chat request with the
	// current system prompt (from the persona). The returned string
	// replaces the system prompt sent to the LLM — the stored conversation
	// is not modified. Use this to inject dynamic context like available
	// skills. Optional: nil = pass through unchanged.
	SystemPromptAugmenter func(ctx context.Context, current string) string

	// HTTPClient overrides the default client. nil = use http.DefaultClient
	// with no timeout (streaming needs no overall timeout).
	HTTPClient *http.Client
}

StandardHost is a ready-to-use Host implementation for apps that:

  • Talk to an OpenAI-compatible /v1/chat/completions endpoint
  • Expose MCP tools/prompts/resources via an *mcp.Server

Hosts with different needs (non-OpenAI LLM, custom tool backends, per-user MCP servers) construct a StandardHost with the appropriate functions. Hosts with fundamentally different architectures implement Host themselves.

Auth is NOT handled here — the host wraps the lmchatkit routes with its own middleware via Config.AuthMiddleware, exactly like it protects any other route. StandardHost never sees tokens, sessions, or passwords.

func (*StandardHost) AugmentSystemPrompt

func (h *StandardHost) AugmentSystemPrompt(ctx context.Context, current string) string

AugmentSystemPrompt satisfies SystemPromptAugmenter. If the function field is nil, the prompt passes through unchanged.

func (*StandardHost) CallTool

func (h *StandardHost) CallTool(ctx context.Context, name string, arguments json.RawMessage) (ToolResult, error)

func (*StandardHost) Complete

func (h *StandardHost) Complete(ctx context.Context, req CompleteRequest, events chan<- Event) error

func (*StandardHost) GetPrompt

func (h *StandardHost) GetPrompt(ctx context.Context, name string, args map[string]string) (PromptResult, error)

func (*StandardHost) ListPrompts

func (h *StandardHost) ListPrompts(ctx context.Context) ([]Prompt, error)

func (*StandardHost) ListResources

func (h *StandardHost) ListResources(ctx context.Context) ([]Resource, error)

func (*StandardHost) ListTools

func (h *StandardHost) ListTools(ctx context.Context) ([]Tool, error)

func (*StandardHost) Models

func (h *StandardHost) Models(ctx context.Context) ([]Model, error)

func (*StandardHost) ReadResource

func (h *StandardHost) ReadResource(ctx context.Context, uri string) (ResourceResult, error)

type StaticCommands

type StaticCommands []SlashCommand

StaticCommands is a CommandSource backed by a fixed slice.

func (StaticCommands) Commands

func (s StaticCommands) Commands(ctx context.Context) ([]SlashCommand, error)

type StaticPersonas

type StaticPersonas []Persona

StaticPersonas is a PersonaSource backed by a fixed slice. Useful for single-tenant hosts that have one system-defined persona (e.g. knot).

func (StaticPersonas) Personas

func (s StaticPersonas) Personas(ctx context.Context) ([]Persona, error)

type StoredConversation

type StoredConversation struct {
	ConversationSummary
	Messages     []Message `json:"messages"`
	EnabledTools []string  `json:"enabled_tools,omitempty"`
}

StoredConversation is a full conversation including messages.

type SystemPromptAugmenter

type SystemPromptAugmenter interface {
	AugmentSystemPrompt(ctx context.Context, current string) string
}

SystemPromptAugmenter is an optional interface a Host can implement to dynamically augment the system prompt on every chat request. The chat handler calls AugmentSystemPrompt with the current system prompt content (from the persona) and uses the returned string when forwarding to the LLM. The stored conversation is not modified — the augmentation is transient, recomputed on each request so it always reflects current state (e.g. skills added/removed at runtime).

type Tool

type Tool struct {
	Name        string                 `json:"name"`
	Description string                 `json:"description,omitempty"`
	InputSchema map[string]interface{} `json:"input_schema,omitempty"`
}

Tool describes one MCP tool exposed to the chat. InputSchema is the JSON schema for arguments (as exposed by MCP tools/list); the frontend uses it to render argument hints when confirming a tool call.

type ToolCall

type ToolCall struct {
	ID        string          `json:"id"`
	Name      string          `json:"name"`
	Arguments json.RawMessage `json:"arguments"`
}

ToolCall is a single tool invocation requested by the model. Arguments is the raw JSON arguments string (the host parses it according to the tool's input schema).

type ToolResult

type ToolResult struct {
	Content string `json:"content"`
	IsError bool   `json:"is_error,omitempty"`
}

ToolResult is the outcome of a tool call. Content is the model-facing text (typically the MCP tool response). isError flags the result as an error so the model knows not to treat Content as a successful payload.

type Usage

type Usage struct {
	PromptTokens     int `json:"prompt_tokens,omitempty"`
	CompletionTokens int `json:"completion_tokens,omitempty"`
	TotalTokens      int `json:"total_tokens,omitempty"`
}

Usage reports token counts for a completion, if known.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL