lmchatkit

package module
v0.12.1 Latest Latest
Warning

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

Go to latest
Published: Oct 11, 2026 License: MIT Imports: 22 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.
  • MCP Apps rendering: a tool result linked to a ui:// resource (SEP-1865) mounts in a sandboxed, auto-resizing iframe instead of plain text — no host code beyond the Host interface's existing CallTool/ReadResource.
  • 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, or `source: "app"` from a mounted view — see [MCP Apps](#mcp-apps-rendering-interactive-tool-uis)) |
| `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.

## MCP Apps (rendering interactive tool UIs)

When a tool result's `ui` field carries a `resourceUri` — [SEP-1865](https://github.com/modelcontextprotocol/ext-apps), the "MCP Apps" extension — the frontend renders that tool call's linked `ui://` resource instead of (or alongside) its plain-text result. No `Host` interface change is needed: `ToolResult.UI` and `ResourceResult.UI` ride along on the existing `CallTool`/`ReadResource` methods, so any host whose backing MCP server declares `_meta.ui` on a tool gets this for free.

### How it works

1. `executeToolCall` sees `data.ui.resourceUri` on a `POST /api/tools/call` response and fetches that resource via the existing `POST /api/resources/read`; the fetched content is stored on `call.appResource`.
2. Once `call.appResource` resolves, the template mounts a container element, whose `x-effect` calls `mountAppView(call, container)` (`web/src/chat.js`) — this builds a CSP `<meta>` tag from the resource's `ui.csp` hints, injects it into the HTML, and renders it into a `<iframe sandbox="allow-scripts allow-forms">` via `srcdoc`.
3. `mountAppView` then plays the **host** side of the extension's `postMessage` protocol: it replies to the view's `ui/initialize` with `hostInfo`/`hostCapabilities`/`hostContext` (including `containerDimensions: { maxHeight: 700 }`, letting the view size itself up to that cap instead of the host guessing a fixed height); after `ui/notifications/initialized` it pushes `ui/notifications/tool-input` (the call's arguments) and `ui/notifications/tool-result` (`content`/`structuredContent`/`isError`); and it proxies any further `tools/call` / `resources/read` the view sends to this server's own `/api/tools/call` / `/api/resources/read` routes and relays the reply.
4. The view reports its own content height via `ui/notifications/size-changed`; `mountAppView` applies it directly to the iframe's `style.height`, so the card grows or shrinks to fit instead of a fixed guess. Only `height` is applied — `width` stays under the iframe's own `w-full` CSS, deliberately, to avoid a resize feedback loop with content that itself reacts to container width (e.g. a Chart.js canvas with `responsive: true`).
5. A tool call an app-only view makes itself (`resolveAppToolName` maps its bare tool name to this call's own `namespace__`-prefixed one, so an app's own script doesn't need to know the federation prefix) round-trips through the same `/api/tools/call` route as a model-initiated call, tagged `source: "app"`. It does **not** go through the model-call approval dialog — an app-driven action (a form submission, a "claim" button) executes as soon as the user interacts with the mounted view, the same way clicking a button in any other web app doesn't ask "are you sure" a second time. What *is* enforced, server-side, on every call regardless of source: [MCP Apps visibility](https://github.com/modelcontextprotocol/ext-apps) (`_meta.ui.visibility`) — a `source: "app"` call may only reach a tool whose visibility includes `"app"`, and a model-approved call may only reach one whose visibility includes `"model"` (`handleCallTool`'s `toolVisibilityAllows`, checked against `Host.ListTools`' full, unfiltered result — not the model-facing list below, which already excludes app-only tools).
6. `Host.ListTools` itself returns every tool regardless of visibility (needed for step 5's lookup); the tool list actually sent to the model goes through `FilterToolsForModel` first, which drops anything whose visibility excludes `"model"` — an app-only action tool is never offered to (or callable by) the model in the first place.
7. `call.ui`/`call.structuredContent`/`call.appResource` persist on the stored `call` object (same mechanism as `call.result`), so reloading the page or switching conversations remounts the same app deterministically instead of losing it.

A note on the trust model: `source: "app"` and `via` are protocol data the *caller* supplies, not authentication — they're honest when they arrive through the view bridge (the bridge hardcodes both; the view can't influence them through `postMessage`), but a direct HTTP client with page credentials could set them to anything. The layers that actually contain a malicious view are structural: the iframe is `srcdoc` with an opaque origin (no `allow-same-origin`, so no access to host DOM/cookies/storage) and a CSP built from the server's declared domains only, and cross-origin `fetch` from the view to `/api/*` fails preflight (no CORS headers). The role/ownership checks narrow what a *spec-conforming* view can reach; the sandbox is what bounds a non-conforming one. Put a real authentication layer in front of `Mount`'s routes for anything exposed beyond localhost — `source`/`via` checking is not one.

See the [MCP Apps guide](https://github.com/paularlott/mcp/blob/main/docs/guides/mcp-apps.md) in `paularlott/mcp` for the server-side half of the contract (`_meta.ui`, CSP metadata, `containerDimensions`/`size-changed`), and `paularlott/mcp`'s `examples/mcp-app-host-harness` for a minimal standalone implementation of the same host-side bridge outside of lmchatkit.

## 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
reasoning_effort = "low"  # reasoning models: low, medium or high

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, reasoning_effort) 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 server merges the persona's params into every request, so ones the chat UI has no field for still reach the model; values set in the UI win.

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/SKILL.md'). Returns the skill content as text.",
	InputSchema: map[string]interface{}{
		"type": "object",
		"properties": map[string]interface{}{
			"uri": map[string]interface{}{
				"type":        "string",
				"description": "The skill URI to retrieve (e.g. skill://golang/SKILL.md)",
			},
		},
		"required": []string{"uri"},
	},
}

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 AllToolsHost added in v0.10.2

type AllToolsHost interface {
	Host
	ListAllTools(ctx context.Context) ([]Tool, error)
}

AllToolsHost is an optional interface a Host can implement to list tools that ListTools omits because they're discoverable-only (e.g. an MCP server's search-only tools, reached by the model through a meta-tool like execute_tool rather than listed directly, to save context). An app view's own tool calls (POST /api/tools/call, Source: "app") name such a tool directly — chat.js resolves it to its real, namespaced name itself — so the visibility check in toolVisibilityAllows needs to find it, even though it would never appear in the model-facing list. A Host whose tools are never discoverable-only need not implement this; lmchatkit falls back to ListTools, same as before this existed.

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

	// FileUpload enables the file-upload button in the chat composer.
	// When true, users can attach images (and other files) which are
	// sent to the LLM as base64-encoded content blocks. Default false —
	// hosts opt in explicitly.
	FileUpload bool

	// MaxUploadBytes caps the size of a single uploaded file. Zero uses
	// the default (10 MB).
	MaxUploadBytes int64
}

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"`
	IsError           bool                   `json:"is_error,omitempty"`
	StructuredContent any                    `json:"structured_content,omitempty"`
	UI                *mcplib.UIToolMeta     `json:"ui,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 `""`.

IsError, StructuredContent, and UI are meaningful only on a RoleTool message (the message recording one tool call's result). They mirror the same-named fields on ToolResult, persisted here so the frontend can reconstruct a tool call's full display — including re-mounting an MCP Apps view — from a reloaded conversation, without re-invoking the tool itself (which may not be idempotent; re-running it could show a different result than what actually happened, or double a side effect).

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"`
	UI       *mcplib.UIResourceMeta `json:"ui,omitempty"`
}

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

UI carries the resource's own _meta.ui (per the MCP Apps extension, SEP-1865) — CSP/permissions/domain/prefersBorder hints a compliant host applies before rendering the content in a sandboxed iframe. Note its JSON field names (e.g. "resourceUri", "connectDomains") are camelCase, unlike the rest of this package's snake_case convention — this reuses the mcp library's own wire type directly rather than duplicating it.

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 SourceScopedHost added in v0.10.0

type SourceScopedHost interface {
	// ToolSource returns an opaque, stable identifier for the MCP server
	// the named tool belongs to ("" for a tool the host serves natively,
	// as opposed to a federated remote), and whether name is a recognized
	// tool at all. Implementations must fail closed: an error resolving
	// the tool must return ok == false, never a guessed source.
	ToolSource(ctx context.Context, name string) (source string, ok bool)

	// ReadResourceFromSource reads uri restricted to the server identified
	// by source (as returned by ToolSource): "" reads only natively-served
	// resources, a non-empty source reads only from that one remote server
	// — never falling through to any other server the way an unscoped
	// ReadResource might.
	ReadResourceFromSource(ctx context.Context, source, uri string) (ResourceResult, error)
}

SourceScopedHost is an optional interface a Host can implement to expose which underlying MCP server each tool/resource belongs to. lmchatkit's /api/tools/call and /api/resources/read handlers use it, when present, to enforce that a mounted MCP Apps view may only reach a tool or resource belonging to the same server as the tool that mounted it — visibility (_meta.ui.visibility) alone can't provide this: two different federated servers connected without namespace prefixes can expose same-shaped "app"-visible tools/resources under names that collide or simply aren't distinguishable by name alone.

A Host that aggregates only a single, non-federated MCP server (no cross-server ambiguity is possible) need not implement this — lmchatkit falls back to allowing the call/read unchecked (beyond the existing visibility check) when the Host doesn't implement it.

type SourcedToolProvider added in v0.10.2

type SourcedToolProvider interface {
	mcplib.ToolProvider
	// ToolSource returns an opaque, stable identifier for the server that
	// owns name (never "" — that's reserved for natively-served tools per
	// SourceScopedHost's contract), or ok=false if this provider doesn't
	// recognize name. Must fail closed: an error resolving the tool returns
	// ok == false, never a guessed source.
	ToolSource(ctx context.Context, name string) (source string, ok bool)
	// ReadResourceFromSource reads uri from the specific server identified
	// by source (as returned by this same provider's ToolSource), or returns
	// an error if source isn't one this provider owns.
	ReadResourceFromSource(ctx context.Context, source, uri string) (*mcplib.ResourceResponse, error)
}

SourcedToolProvider is an optional interface a request-scoped mcplib.ToolProvider (attached via mcplib.WithToolProviders) can implement to identify which underlying MCP server one of its tools came from, and to read a resource restricted to that one server. Without this, StandardHost's SourceScopedHost support (ToolSource/ReadResourceFromSource) only sees servers registered directly on the *mcp.Server via RegisterRemoteServer* — mcp.Server's own ToolSource/ReadResourceFrom have no visibility into context-attached providers at all. A host with per-user/per-request federated servers (e.g. each user's own configured remote MCP servers) needs this to support MCP Apps' cross-server ownership enforcement for those providers; see SourceScopedHost for why that enforcement exists.

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 unless ChatCompletionsURL is set.
	OpenAIBaseURL string

	// ChatCompletionsURL, when set, is the full chat completions URL to post
	// to instead of OpenAIBaseURL + "/v1/chat/completions" — for
	// OpenAI-compatible endpoints at another path (e.g. Gemini's
	// .../v1beta/openai/chat/completions).
	ChatCompletionsURL 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) ListAllTools added in v0.10.2

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

ListAllTools satisfies AllToolsHost. It additionally includes discoverable (search-only) tools that ListTools omits from the model-facing list — see mcplib.WithShowAllTools — so toolVisibilityAllows can find a tool an app view calls directly by its real name.

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)

func (*StandardHost) ReadResourceFromSource added in v0.10.0

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

ReadResourceFromSource satisfies SourceScopedHost. It tries any context-attached [SourcedToolProvider]s first (they're the only ones who can resolve a source identifier they themselves handed out via ToolSource), falling back to the underlying *mcp.Server's own ReadResourceFrom for native/statically-registered sources.

func (*StandardHost) ToolSource added in v0.10.0

func (h *StandardHost) ToolSource(ctx context.Context, name string) (string, bool)

ToolSource satisfies SourceScopedHost. It checks the underlying *mcp.Server's own ToolSource first (native tools and statically-registered remote servers), then any context-attached [SourcedToolProvider]s.

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"`
	Visibility  []string               `json:"-"`
}

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.

Visibility is the tool's _meta.ui.visibility (per the MCP Apps extension, SEP-1865): who may call it — "model" (the agent), "app" (a mounted view), or both. Nil/empty means both, per the spec's default. ListTools returns every tool regardless of visibility (callers needing the model-facing subset use FilterToolsForModel); it's carried here so the /api/tools/call handler can enforce it without a second round trip to the MCP server.

func FilterToolsForModel added in v0.10.0

func FilterToolsForModel(tools []Tool) []Tool

FilterToolsForModel returns the subset of tools visible to the model, per the MCP Apps extension's tools/list MUST: a tool whose _meta.ui.visibility doesn't include "model" (e.g. an app-only action tool like a form submission) must never reach the agent's own tool list, even though it's still a real, callable tool for a mounted view. Hosts building the LLM-facing tool array from Host.ListTools's result MUST filter through this first — StandardHost's ListTools deliberately returns every tool unfiltered, since the same list also backs the /api/tools/call visibility check for app-initiated calls, which needs the app-only tools.

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"`
	StructuredContent any                `json:"structured_content,omitempty"`
	UI                *mcplib.UIToolMeta `json:"ui,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.

StructuredContent and UI are frontend-only extras, not sent to the model: StructuredContent is the tool response's raw structuredContent (if any), which Content's text-only flattening otherwise discards entirely. UI is the called tool's own _meta.ui (per the MCP Apps extension, SEP-1865), when the underlying MCP server declared one — the frontend uses it to decide whether to render an interactive view for this result.

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