DisPatch: A Self-Hosted Chat App for Local AI Agents
- Category
- AI & Local LLM
- Posted
- July 11, 2026
- Updated
- July 11, 2026
- By
- Jacob Lloyd β written with AI assistance, post-project
- Read time
- 11 min read
In plain terms: DisPatch is three things in one, hosted entirely on your own computer: a chat app for talking to AI assistants that also live there, a file server, and a terminal β available on all your devices at all times, with nothing going to a cloud service. Its file server moves files from any device onto the computer the assistants run on, so they can work with what you upload. On shared devices, a safe subset of assistants is available without a PIN; entering the PIN unlocks the full-power ones.
DisPatch is the app I built so I could stop running a 30-container Rocket.Chat install just to text my own AI agents. It ended up as three things in one β a locally hosted LLM chat app, a file server, and a terminal β available on every device I own, at all times. It's a ~720KB zip, it's free, and it's the only chat front end I use now.
TL;DR
- What it is: Three things in one, self-hosted: a web chat for a stable of local AI agents (an OpenClaw gateway), a drag-drop file server, and a real terminal β on every device on your LAN or Tailscale network, at all times.
- What it costs: $0. No SaaS seat, no account.
- What you need: Linux, Python 3.12+, and
uv. An OpenClaw gateway if you want typed replies β everything else works without one. - What you end up with: per-bot threads, streaming markdown, a drag-drop File Server, an in-app coding-agent terminal, a PIN-lockable "Safe Mode", a push API for cron jobs, and 187 tests that pass straight out of the zip.
What you end up with
Before any setup talk, this is what it looks like doing its job. Shots are from a sandboxed copy with dummy data β nobody needs to see my actual bot logs.



What it actually does
- Per-bot threads, everywhere. Open it on your phone mid-conversation and it's the same thread, same history, no re-login dance.
- A File Server β drag-drop files between every device and the box. The feature I use most; it gets its own section below.
- A terminal (Terminex) β a real shell in the browser running a coding agent. Also its own section below.
- Streaming markdown replies with syntax-highlighted code (copy button included), images/video with a zoom lightbox, installable as a PWA.
- A live "working" panel. Click the typing dots to watch the model's tool calls and thinking live β it's tailing the agent's transcript, not faking a spinner.
- Two-tier lock. A PIN separates the full-power agents from a safe, general-info subset β details in the two-tier lock section below.
- A push API β
POST /api/injectβ so a cron job or another agent can drop a message (or an image) into a thread. That's how a daily briefing lands on your phone without you asking. - Local image generation in the thread. Ask an agent for a picture and it can call a local ComfyUI instance; the result lands in the chat like any other media, lightbox and all. No image API, no upload to anyone.
- Optional, off by default: a ComfyUI panel for starting or stopping that image-gen service from chat.
The File Server
Of everything in DisPatch, the File Server is the piece I use the most. It's a drag-drop file area served from the same box: open DisPatch on any device, drop a file on it, and the file is on the server β grouped by day, with thumbnails for images and video.

What makes it more than a convenience is where the files land: on the same machine the agents live on. A photo uploaded from my phone can be read off disk by any agent seconds later β described, filed, put on a website, fed to the image tools. It's the bridge between "a file on whatever device I'm holding" and "a file my agents can work with," with no cloud drive in the middle and no cables.
The unglamorous parts are handled: uploads are size-checked before they're buffered (a phone can't OOM the box), there's an overall storage cap, writes are atomic so an interrupted upload never leaves a half-written file, and the file store is mirrored into the same rotated backup scheme as the chat history. A config flag can also flip the whole thing into a one-way, upload-only drop box β devices can add files but not browse them β for setups where more people can reach the app than should be able to read its archive.
Terminex: a terminal in the chat
The other feature that earns its keep daily: DisPatch can put a real terminal in the browser. It appears in the bot rail like any other bot, but opening it attaches you to a server-side PTY streamed over a WebSocket into xterm.js β an actual shell session on the box, not a mock.

Mine launches straight into Reasonix, the Claude-Code-style coding agent I run on DeepSeek β the setup the assistant hub page calls Terminex. That turns a chat tab into a full coding-agent console: hand it a job, watch it work, from any device that can open the app. Paired with the File Server it makes the box fully driveable from a phone β drop a file in from wherever you are, open the terminal, tell the agent what to do with it.
- Unlocked mode only. The terminal doesn't exist in Safe Mode, and it stays off entirely unless you enable it (
LOCAL_CHAT_TERMINAL). - The session is in-memory. Start / Restart / Stop from the app; a server restart ends it. There's no persistent shell hiding in the background.
Why it exists
It replaced a full Rocket.Chat deployment β a database and a pile of containers so one person could talk to some local models. It worked, but that's a lot of infrastructure for "send text, get text back, sync it everywhere."
And the deeper answer to "why self-host a chat app at all": once your models run locally, the chat UI is the last piece still tempting you toward a cloud service. Self-hosting it means your prompts, your agents' output, and your family's messages never leave the house, and the whole loop β UI, gateway, models, image generation β keeps working with the internet unplugged. If you've already committed to local LLMs, a hosted chat front end is the odd one out.
One design choice worth calling out: DisPatch has no build step, on purpose. The frontend is vanilla JS (ES modules) and plain CSS/HTML. No bundler, no npm run build. My actual requirement going in was "easy for an AI agent to modify later." Edit a JS file, hit refresh, done. I'd make the same call again.
The stack
| Layer | What it is |
|---|---|
| Backend | Python 3.12+, FastAPI + uvicorn, aiosqlite (one SQLite file, WAL mode), Pillow, managed with uv |
| Backend size | ~7,200 lines in backend/app/ (main.py alone is ~3,600), plus ~2,700 lines of tests |
| Frontend | No-build vanilla JS + HTML/CSS β ~4,900 lines of JS, ~2,100 of CSS/HTML/service-worker |
| Vendored libs | marked, highlight.js, DOMPurify, xterm.js β no CDN calls |
| Data | Outside the code at ~/.local/share/local-chat/: DB, config.yaml, security.yaml, media/, files/, rotated DB backups |
Setting it up
Condensed from README-SETUP.md inside the zip (download box below).
unzip dispatch-2026-07.zip
cd dispatch
./install.sh
./run.sh
That starts it on http://0.0.0.0:8765 and prints the LAN and Tailscale URLs, so you can tap one from your phone. From there:
- Edit
~/.local/share/local-chat/config.yaml(auto-created, documented inconfig.yaml.example) so the botids match your OpenClaw agent ids. It hot-reloads. - Optional:
./install.sh --systemdrenders a user systemd unit. - Optional: set a PIN from the gear icon β Security.
security.yaml.exampledocuments the file, includingapi_tokenfor anyone hitting/api/injectfrom off-box. - Env knobs:
LOCAL_CHAT_HOST/PORT/DATA_DIR/AGENT_TIMEOUT/MAX_CONCURRENCY, plusLOCAL_CHAT_COMFY=0/LOCAL_CHAT_TERMINAL=0to keep the optional panels off.
The openclaw CLI itself isn't included β DisPatch expects an OpenClaw gateway already running somewhere it can reach. No gateway yet? The app still starts, warns you in the logs, and everything except typed replies works: threads, the inject API, the file server, PIN and Safe Mode. That's how I smoke-tested this zip β fresh unzip, uv sync, spare port, no gateway β and the packaged test suite ran 187 passed in 8.4s.
How a typed message actually happens
You type something and hit send. Here's the path that takes:
That's the happy path. The engineering went into the unhappy ones: the CLI times out, crashes, or a subagent answers ten minutes late. Four independent sources β live watcher, the CLI's JSON payload, a post-turn reconciler that re-scans the transcript, and a "follower" that tails for another ~30 minutes β feed one delivery funnel with turn-scoped dedup (canonicalized text plus media fingerprint). Any one path can fail and the reply still lands exactly once. Even on a CLI error the reconciler runs first, because the model often flushed its answer to the transcript before dying.
The two-tier lock: normal vs. Safe Mode
The distinction matters because the unlocked side isn't a toy. Those agents can actually do things: edit source code, push changes to my live website, run tools, manage the machine they live on. The locked side is general info only β still genuinely usable, you can ask questions, chat, and get real help β but deliberately fenced off from all of that power. The reason is simple: I have a three-year-old, and the family phone is not always in adult hands. Imagine what a three-year-old could do with full access from my phone β the site would be entirely Paw Patrol videos. So Safe Mode is child-proofing, enforced on the server. One small quality-of-life touch: the locked home screen now shows the same bot avatars as the unlocked side, instead of the old letter-block placeholders. Here's what happens when a request arrives with no PIN entered yet:
Lock yourself out and there's a recovery code file plus an editable YAML, not a support ticket. And if security.yaml gets corrupted mid-write, the failure mode is "stays locked," not "serves everything to anyone on the LAN." That was a deliberate call.
Gotchas
- The API field is
content, nottext.POST /api/injectwants{"bot_id": "...", "content": "..."}. The single most common integration mistake, including ones I've made myself. - Don't pass long messages as a CLI argument. The kernel caps argv at 128KB (
MAX_ARG_STRLEN); a long reply crashes the spawn with E2BIG. The code uses--message-file <tmpfile>β don't "simplify" it back to an argument. - Bot ids are case-sensitive in one specific spot. OpenClaw's gateway lowercases session keys in its index, but your ids are mixed-case (
Doxy,DS_Brain). A case-sensitive lookup silently kills the working panel and the late-answer follower while everything else keeps working β nasty to notice. Exact match first, lowercase fallback. - The CLI only returns the final block. Anything an agent says between tool calls exists only in its transcript, not the CLI's return value β hence the watcher and reconciler, and the special-casing for an agent's "here's what I found so far" while it waits on a subagent.
- Cross-site WebSocket hijacking is blocked by an Origin check. Both WS routes reject a handshake whose
Originhost doesn't matchHost; non-browser clients send no Origin and pass fine. - SVGs are deliberately not servable as media. A browser treats inline SVG as same-origin script β serving one back as an image is a stored-XSS hole. Media responses get a sandboxing CSP and nosniff instead.
- Service-worker updates leave old tabs stuck on old JS. Looks exactly like a regression until you realize the tab never picked up the new worker. Fixed with an auto-reload on
controllerchange.
Replicating the idea
Even if you never run my zip, the architecture is worth stealing. The whole thing is four independent pieces, each replaceable:
- A chat backend that owns the history: one small web server (here FastAPI + a single SQLite file in WAL mode) with a WebSocket for live updates and a plain HTTP endpoint for pushing messages in. That's the entire "database and pile of containers" replacement.
- An agent gateway the backend shells out to or calls over HTTP β here OpenClaw, but anything that takes "session id + message in, text out" works. Keeping it a separate process means the chat app survives the agent stack crashing, being upgraded, or being swapped entirely.
- Local model servers behind the gateway (LM Studio, vLLM, Ollama β the gateway's problem, not the chat app's).
- Optional media services, like a local ComfyUI for image generation, that agents call as tools and whose output the backend just stores as media.
The lessons that transfer regardless of stack: enforce your safe/locked mode on the server, never in CSS; treat "the model's reply reached the user exactly once" as a real distributed-systems problem, not a happy-path assumption; and keep the frontend build-free if you expect AI agents to be the ones maintaining it.
Downloads
Free for personal use. If it saves you an afternoon, the coffee button's nearby.