term-llm already has the hard parts of a private web system: authentication, an HTTPS front door, a chat UI, model access, local tools, and a process that is allowed to talk to the weird things on my machine.

That makes it a tempting place to put small web apps.

Not a plugin marketplace. Not Kubernetes in a trench coat. Just a way to say:

title: "Sam's Hebrew Keyboard"
mount: hebrew
command: ["uv", "run", "python", "server.py", "--socket", "$SOCKET"]

and have this appear at:

/chat/widgets/hebrew/

On first hit, term-llm starts the command, gives it either a Unix socket or localhost port, waits until it responds, and reverse-proxies traffic to it. When it goes idle, term-llm can stop it. When a new widget is dropped on disk, term-llm can reload the registry without restarting chat.

The feature should be explicitly enabled:

term-llm serve --enable-widgets

If the flag is absent, none of the widget routes or admin endpoints are registered. This keeps the default server surface unchanged and makes the feature feel intentional rather than surprising.

That is the whole feature.

The important part is what is deliberately missing: no LLM-specific protocol, no action schemas, no per-widget permission maze, no install API, no remote proxying, no iframe framework. A widget is just a local web process mounted under the authenticated chat origin.

That small primitive is absurdly powerful. You can build a Hebrew keyboard. You can build a skill manager. You can build an alternative chat frontend. You can keep replacing parts of the ship while sailing it, which is either software architecture or a warning from mythology.

The core idea

A widget is a directory under term-llm’s widget directory:

~/.config/term-llm/widgets/
  hinglish-keyboard/
    widget.yaml
    server.py
    pyproject.toml
    static/
      app.js
      style.css

The manifest is tiny:

title: "Sam's Hebrew Keyboard"
mount: hebrew
description: "Type English-ish text and get editable Hebrew."
command: ["uv", "run", "python", "server.py", "--socket", "$SOCKET"]

The directory name is the widget’s local identity:

hinglish-keyboard

The optional mount controls where it appears under /chat/widgets/:

/chat/widgets/hebrew/

If mount is omitted, term-llm uses the directory name:

/chat/widgets/hinglish-keyboard/

The command is an argv array. No shell string. No quoting games. No accidental sh -c injection surface.

The name

widget is the right name for v1.

It is small enough that it does not imply a full plugin framework, but concrete enough to mean “a little mounted UI”. app sounds too large. plugin implies lifecycle, permissions, and extension APIs. tool collides with agent tools. widget says: this is a small web surface attached to the host.

Use the singular for the CLI namespace:

term-llm widget list
term-llm widget reload
term-llm widget status
term-llm widget stop hebrew

Use plural for URLs because the route is a collection:

/chat/widgets/hebrew/

That gives a clean split: widget is the command family, /widgets/ is the mounted collection.

Manifest v1

Required fields:

FieldMeaning
titleHuman-readable name shown in widget listings.
commandArray of strings used to start the local widget server. Must include exactly one of $SOCKET or $PORT.

Optional fields:

FieldMeaning
mountURL component under /chat/widgets/. Defaults to the widget directory name.
descriptionShort explanation shown in listings/status pages.

That is it for v1.

No version. Maybe later. A version field is useful once there is more than one schema. Starting with version: 1 in a four-field manifest is paperwork cosplay.

No slug. It is technically correct web jargon, which is the worst kind of correct. The thing is mounted, so call it mount.

No proxy.mode. The command itself tells term-llm what kind of listener to use.

Socket and port substitution

The command must contain exactly one of these placeholders:

PlaceholderMeaning
$SOCKETterm-llm allocates a Unix domain socket path and proxies HTTP over it.
$PORTterm-llm allocates a localhost TCP port and proxies HTTP to it.

Socket example:

title: "Sam's Hebrew Keyboard"
mount: hebrew
command: ["uv", "run", "python", "server.py", "--socket", "$SOCKET"]

Port example:

title: "React Prototype"
mount: react-prototype
command: ["npm", "run", "dev", "--", "--host", "127.0.0.1", "--port", "$PORT"]

term-llm performs literal substitution before exec.

This:

command: ["server", "--listen=unix:$SOCKET"]

may become:

server --listen=unix:/tmp/term-llm-widgets/hinglish-keyboard.sock

This is not shell expansion. $SOCKET and $PORT are just placeholder strings inside argv values.

If the command contains $SOCKET, term-llm uses socket mode. If it contains $PORT, term-llm uses port mode. If it contains both, the manifest is invalid. If it contains neither, the manifest is invalid.

Enabling widgets

Widgets are off by default.

Start the web server with:

term-llm serve --enable-widgets

When enabled, term-llm registers:

GET  /chat/widgets/
GET  /chat/widgets/{mount}/*
POST /chat/admin/widgets/reload
GET  /chat/admin/widgets/status
POST /chat/admin/widgets/{mount}/stop

When disabled, those routes do not exist. The widget directory is not scanned, no widget processes are started, and the admin endpoints are unavailable.

This matters because proxied widgets are powerful. They let local code appear behind the authenticated chat front door. That should be opt-in.

Runtime defaults

Everything else is defaulted:

SettingDefault
Widget directory~/.config/term-llm/widgets
Runtime directory/tmp/term-llm-widgets
Working directoryThe widget directory
External path/chat/widgets/{mount}/
Internal proxied path/ with the mount prefix stripped
Health checkGET /
Startup timeout10 seconds
Idle timeout10 minutes
TCP host127.0.0.1
Socket path{runtime_dir}/{widget-id}.sock

The widget directory name remains the stable widget ID even when mount overrides the public URL. That means this directory:

widgets/hinglish-keyboard/

with this manifest:

mount: hebrew

uses:

id: hinglish-keyboard
mount: hebrew
url: /chat/widgets/hebrew/
socket: /tmp/term-llm-widgets/hinglish-keyboard.sock

Changing the public mount should not rename runtime files.

Environment variables

The placeholders are enough to start most widgets, but term-llm should also provide useful environment variables.

Always:

TERM_LLM_WIDGET_ID=hinglish-keyboard
TERM_LLM_WIDGET_MOUNT=hebrew
TERM_LLM_WIDGET_BASE_PATH=/chat/widgets/hebrew
BASE_PATH=/chat/widgets/hebrew

Socket mode:

TERM_LLM_WIDGET_SOCKET=/tmp/term-llm-widgets/hinglish-keyboard.sock
SOCKET=/tmp/term-llm-widgets/hinglish-keyboard.sock

Port mode:

TERM_LLM_WIDGET_HOST=127.0.0.1
TERM_LLM_WIDGET_PORT=43125
HOST=127.0.0.1
PORT=43125

The TERM_LLM_* names are the real contract. The short names are convenience.

Request flow

A browser requests:

GET /chat/widgets/hebrew/api/translate

term-llm:

  1. authenticates the request using the normal /chat auth;
  2. resolves hebrew to the widget directory hinglish-keyboard;
  3. starts the widget if it is not already running;
  4. strips the mount prefix;
  5. proxies the request to the widget.

The widget receives:

GET /api/translate

So the widget can behave as though it lives at /.

The browser never sees the socket path or port.

Canonical trailing slash

/chat/widgets/hebrew should redirect to:

/chat/widgets/hebrew/

Use a permanent-ish redirect such as 308.

This matters because relative browser URLs are different with and without the slash. With the slash:

<script src="./static/app.js"></script>

resolves to:

/chat/widgets/hebrew/static/app.js

Without the slash, browsers try to be clever in the way only browsers can, which is to say: expensively wrong.

Prefix stripping and headers

The proxy strips the external mount prefix:

/chat/widgets/hebrew/foo
→ /foo

It should also send forwarding headers:

X-Forwarded-Prefix: /chat/widgets/hebrew
X-Forwarded-Host: wasnotwas.com
X-Forwarded-Proto: https
X-Forwarded-For: <client-ip>
X-Term-LLM-Widget: hinglish-keyboard

Widgets should use relative URLs where possible. If they need to generate absolute URLs, they can use BASE_PATH or X-Forwarded-Prefix.

No HTML rewriting. That path leads to cursed regexes, broken CSP, and a small but permanent stain on the soul.

Socket mode

Socket mode is the preferred default for term-llm-owned widgets.

Manifest:

title: "Sam's Hebrew Keyboard"
mount: hebrew
command: ["uv", "run", "python", "server.py", "--socket", "$SOCKET"]

Startup:

uv run python server.py --socket /tmp/term-llm-widgets/hinglish-keyboard.sock

The widget listens on the Unix socket. term-llm sends HTTP over that socket.

Benefits:

A Python/FastAPI widget can support this directly with uvicorn:

import argparse
import uvicorn
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def index():
    return "<h1>Sam's Hebrew Keyboard</h1>"

@app.get("/healthz")
def healthz():
    return {"ok": True}

parser = argparse.ArgumentParser()
parser.add_argument("--socket")
parser.add_argument("--port", type=int)

if __name__ == "__main__":
    args = parser.parse_args()
    if args.socket:
        uvicorn.run(app, uds=args.socket)
    else:
        uvicorn.run(app, host="127.0.0.1", port=args.port)

Port mode

Port mode exists for frameworks and dev servers that are happier with TCP.

title: "Vite Demo"
mount: vite-demo
command: ["npm", "run", "dev", "--", "--host", "127.0.0.1", "--port", "$PORT"]

term-llm allocates a local port, starts the process, then proxies to:

http://127.0.0.1:{PORT}/

The port must bind to localhost. This is not a public server. term-llm is the public-ish authenticated front door.

Lazy startup

On first request to a stopped widget:

  1. term-llm chooses the endpoint: socket path or TCP port;
  2. removes any stale socket file if using socket mode;
  3. substitutes $SOCKET or $PORT in the command;
  4. starts the process in the widget directory;
  5. polls GET / until it receives a response;
  6. proxies the original request.

If startup fails, return 502 Bad Gateway with a useful error for the authenticated user:

Widget hebrew failed to start: health check timed out after 10s

Also keep recent stdout/stderr around for status/debug pages.

The first browser load will usually request several resources at once:

/
/static/app.js
/static/style.css
/favicon.ico

term-llm must use a per-widget singleflight lock so only one process starts. Other requests wait for the same startup attempt.

Idle shutdown

Widgets should not run forever unless they are being used.

Default policy:

idle timeout: 10 minutes

Every proxied request updates last_access.

A cleanup loop runs periodically:

for each running widget:
  if now - last_access > idle_timeout:
    SIGTERM
    wait a few seconds
    SIGKILL if still alive
    remove socket file if applicable

The idle timeout is a default, not a manifest field in v1. If enough real widgets need custom timeouts, add it later. Do not pre-pay complexity tax.

Reload behavior

term-llm should not require a restart to discover widgets.

There are two ways to reload.

From the CLI:

term-llm widget reload

Or through the authenticated web admin endpoint:

POST /chat/admin/widgets/reload

Useful CLI commands:

term-llm widget list
term-llm widget status
term-llm widget reload
term-llm widget stop hebrew

The singular widget namespace is intentional: it reads like other command families, while /chat/widgets/ remains the plural web collection.

Routes:

GET  /chat/widgets/
POST /chat/admin/widgets/reload
GET  /chat/admin/widgets/status
POST /chat/admin/widgets/{mount}/stop

Reload scans:

~/.config/term-llm/widgets/*/widget.yaml

Algorithm:

  1. list widget directories in deterministic sorted order;
  2. parse each widget.yaml;
  3. validate title, mount, and command;
  4. build a new registry in memory;
  5. compare the new registry with the old one;
  6. stop running widgets that were removed or changed;
  7. atomically swap the registry.

A manifest change includes:

If only the title changed, restarting the process is not technically required. Still, v1 can treat any manifest change as a restart boundary. Simple and predictable.

If a widget changes mount from hebrew to keyboard, reload should:

Invalid widgets are skipped and reported. They should not prevent the rest of chat from working.

Example reload response:

{
  "ok": true,
  "loaded": [
    {
      "id": "hinglish-keyboard",
      "mount": "hebrew",
      "title": "Sam's Hebrew Keyboard",
      "listen": "socket"
    },
    {
      "id": "skill-manager",
      "mount": "skills",
      "title": "Skill Manager",
      "listen": "socket"
    }
  ],
  "errors": [
    {
      "id": "broken-widget",
      "error": "command must contain exactly one of $SOCKET or $PORT"
    }
  ]
}

Status response:

{
  "widgets": [
    {
      "id": "hinglish-keyboard",
      "mount": "hebrew",
      "title": "Sam's Hebrew Keyboard",
      "listen": "socket",
      "running": true,
      "started_at": "2026-05-06T09:31:00Z",
      "last_access": "2026-05-06T09:34:12Z"
    }
  ]
}

The listing page at /chat/widgets/ can be plain HTML. It should show title, description, mount URL, and whether the widget is currently running. It does not need to become a dashboard. Dashboards are how simple systems learn to wear a tie.

Validation rules

For each widget directory:

This intentionally does not try to sandbox the process. Widgets are local code installed by the operator. The security boundary is: widget routes are behind normal /chat auth and only local manifests are loaded.

Authentication and headers

All widget routes inherit normal chat authentication.

Rule:

If you can access /chat, you can access /chat/widgets/*.

term-llm should not forward browser cookies or authorization headers to widgets by default. The widget does not need them; term-llm has already authenticated the request.

Strip:

Cookie
Authorization

Forward useful context:

X-Forwarded-Prefix
X-Forwarded-Host
X-Forwarded-Proto
X-Forwarded-For
X-Term-LLM-Widget

If a later widget needs stronger defense-in-depth, term-llm can inject an internal runtime header and provide the same secret in an env var. That is optional. Do not put it in v1 unless something actually needs it.

Error behavior

SituationResponse
Unknown mount404 Not Found
Invalid manifestWidget is not mounted; error appears in reload/status output
Startup timeout502 Bad Gateway
Process exits during request502 Bad Gateway
Socket disappearsStop runtime, return 502, next hit attempts restart
Reload removes running widgetStop process, remove route

For an authenticated personal system, errors should be useful rather than coy. Show the widget ID, mount, and last startup error. Hide secrets from logs, but do not make debugging feel like reading tea leaves through frosted glass.

Three example widgets

1. Sam’s Hebrew Keyboard

The original motivation: a tiny UI for composing Hebrew from English-ish or phonetic input.

title: "Sam's Hebrew Keyboard"
mount: hebrew
description: "Type English-ish text and get editable Hebrew."
command: ["uv", "run", "python", "server.py", "--socket", "$SOCKET"]

Mounted at:

/chat/widgets/hebrew/

The app can provide:

It can call model APIs however the widget author chooses. The mounting system does not care. That is the point.

2. Skill Manager

A widget for inspecting and editing term-llm skills.

title: "Skill Manager"
mount: skills
description: "Browse, validate, and edit local term-llm skills."
command: ["uv", "run", "python", "app.py", "--socket", "$SOCKET"]

Mounted at:

/chat/widgets/skills/

This could expose:

This is where the power starts to show. term-llm can grow a web management surface without hard-coding that surface into the main chat UI. The skill manager is just another mounted local app.

3. Alternative Chat Frontend

A completely different frontend for the same backend.

title: "Minimal Chat"
mount: minimal-chat
description: "A stripped-down alternative frontend for term-llm conversations."
command: ["node", "server.js", "--port", "$PORT"]

Mounted at:

/chat/widgets/minimal-chat/

This widget might implement:

This is the Ship of Theseus part. Once widgets can sit behind the same authenticated front door, the main UI is no longer sacred. You can build a replacement beside it, use it, improve it, and eventually move more traffic there. The old ship remains in the harbor while the new one quietly steals its planks.

What not to build in v1

Explicit non-goals:

HTTP proxying and streaming responses should work. WebSockets can be best-effort or v2. Do not let websocket perfection block the primitive.

Implementation sketch

The code needs four pieces.

1. Registry loader

Scan widget directories and parse manifests into something like:

type WidgetManifest struct {
    ID          string
    Title       string `yaml:"title"`
    Mount       string `yaml:"mount"`
    Description string `yaml:"description"`
    Command     []string `yaml:"command"`
    Listen      string // derived: "socket" or "port"
}

ID comes from the directory basename. Listen comes from scanning the command for $SOCKET or $PORT.

2. Runtime manager

Owns running widget processes:

type WidgetRuntime struct {
    ID         string
    Mount      string
    Listen     string
    Cmd        *exec.Cmd
    Port       int
    SocketPath string
    StartedAt  time.Time
    LastAccess time.Time
    LastError  string
}

Methods:

EnsureRunning(id string) (*WidgetRuntime, error)
Stop(id string) error
StopAll() error
CleanupIdle()

3. Reverse proxy handler

For:

/chat/widgets/{mount}/*

Do:

auth
lookup mount
ensure running
strip prefix
proxy to socket or localhost port

For Unix sockets, use an HTTP transport with a custom dialer:

transport := &http.Transport{
    DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
        return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
    },
}

The URL can still pretend to be HTTP:

http://term-llm-widget/

The dialer sends it over the socket.

4. Serve flag and admin endpoints

The feature is enabled explicitly:

term-llm serve --enable-widgets

Without that flag, widget routes and widget admin endpoints are not registered.

When enabled, expose:

GET  /chat/widgets/
POST /chat/admin/widgets/reload
GET  /chat/admin/widgets/status
POST /chat/admin/widgets/{mount}/stop

The admin endpoints are authenticated the same way as chat. No new auth system.

5. CLI commands

The CLI should mirror the admin endpoints:

term-llm widget list
term-llm widget status
term-llm widget reload
term-llm widget stop hebrew

list reports discovered widgets, including invalid manifests. status reports runtime state. reload rescans manifests in the live server. stop terminates a running widget process by mount name.

Why this is the right primitive

The feature is not really about widgets.

It is about giving term-llm a small, composable way to grow web surfaces without bloating the main application. The harness already knows how to authenticate, route, observe, and run local commands. A proxied widget is just the missing joint.

The manifest stays small enough to remember:

title: "Thing"
mount: thing
command: ["thing", "--socket", "$SOCKET"]

The behavior stays useful enough to matter:

drop folder → reload → first hit starts → proxy → idle stop

That is the good kind of magic. Not magic because the system is hiding policy from you. Magic because the right default makes the boring plumbing disappear.

And once the plumbing disappears, term-llm stops being only a chat application. It becomes a private workbench: a place where tiny tools can appear, mutate, replace each other, and eventually replace parts of the host itself.

Ship of Theseus, but with YAML and a socket file. Worse fates exist.