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:
| Field | Meaning |
|---|---|
title | Human-readable name shown in widget listings. |
command | Array of strings used to start the local widget server. Must include exactly one of $SOCKET or $PORT. |
Optional fields:
| Field | Meaning |
|---|---|
mount | URL component under /chat/widgets/. Defaults to the widget directory name. |
description | Short 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:
| Placeholder | Meaning |
|---|---|
$SOCKET | term-llm allocates a Unix domain socket path and proxies HTTP over it. |
$PORT | term-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:
| Setting | Default |
|---|---|
| Widget directory | ~/.config/term-llm/widgets |
| Runtime directory | /tmp/term-llm-widgets |
| Working directory | The widget directory |
| External path | /chat/widgets/{mount}/ |
| Internal proxied path | / with the mount prefix stripped |
| Health check | GET / |
| Startup timeout | 10 seconds |
| Idle timeout | 10 minutes |
| TCP host | 127.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:
- authenticates the request using the normal
/chatauth; - resolves
hebrewto the widget directoryhinglish-keyboard; - starts the widget if it is not already running;
- strips the mount prefix;
- 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:
- no port allocation collisions;
- no accidental network exposure;
- easy cleanup;
- clear local-only semantics.
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:
- term-llm chooses the endpoint: socket path or TCP port;
- removes any stale socket file if using socket mode;
- substitutes
$SOCKETor$PORTin the command; - starts the process in the widget directory;
- polls
GET /until it receives a response; - 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:
- list widget directories in deterministic sorted order;
- parse each
widget.yaml; - validate title, mount, and command;
- build a new registry in memory;
- compare the new registry with the old one;
- stop running widgets that were removed or changed;
- atomically swap the registry.
A manifest change includes:
- title changed;
- mount changed;
- command changed;
- description changed.
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:
- remove
/chat/widgets/hebrew/; - add
/chat/widgets/keyboard/; - stop the running process because
BASE_PATHchanged; - start fresh on the next hit.
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:
widget.yamlmust exist;- directory basename must match
^[a-z0-9][a-z0-9-]{0,63}$; titlemust be present;commandmust be a non-empty array of strings;commandmust contain exactly one of$SOCKETor$PORT;mount, if present, must match^[a-z0-9][a-z0-9-]{0,63}$;- duplicate mounts are rejected deterministically;
- command runs with working directory set to the widget directory;
- no shell command strings;
- no remote upstream URLs in v1.
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
| Situation | Response |
|---|---|
| Unknown mount | 404 Not Found |
| Invalid manifest | Widget is not mounted; error appears in reload/status output |
| Startup timeout | 502 Bad Gateway |
| Process exits during request | 502 Bad Gateway |
| Socket disappears | Stop runtime, return 502, next hit attempts restart |
| Reload removes running widget | Stop 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:
- a text box for English/phonetic input;
- generated Hebrew output;
- clickable words or phrases with alternatives;
- copy-to-clipboard;
- local preferences for common choices.
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:
- installed skills;
- source paths;
- descriptions;
- validation errors;
- bundled scripts/assets;
- a safe edit/reload workflow.
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:
- a fast keyboard-first chat UI;
- different transcript rendering;
- focused mobile layout;
- specialized debugging panels;
- experiment-only interaction models.
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:
- no LLM action protocol;
- no prompt schema registry;
- no per-widget permissions;
- no remote proxying;
- no public widget install API;
- no Docker orchestration;
- no HTML rewriting;
- no marketplace;
- no database-backed registry;
- no user-uploaded manifests;
- no websocket requirement for launch.
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.