A self-hosted, multi-channel agentic assistant in the spirit of OpenClaw. Meridian takes a message from a channel, assembles context, calls an LLM with a tool schema, executes the tools the model asks for, and loops until the task is done.
Built on a small, boring stack: Express + SQLite, routing across hosted providers (Claude, OpenAI, Gemini) and a local Ollama instance.
π Full spec:
docs/meridian-prd.mdΒ· ποΈ How it's built:docs/system-design.md
Meridian is usable across three channels, sharing one agent core:
- CLI β one-shot
meridian "..."(and piped input); runs on Node built-ins, no install. - TUI β a live Ink chat shell: streaming markdown, tool activity, inline approvals, input history/multiline, cancel, slash commands.
- Web UI β an auth'd localhost server (
meridian serve) with a React/Tailwind chat (streaming, tool activity, approvals, cancel, runtime controls, light/dark) and a dashboard of runs/traces and token/cost charts.
Under the hood:
- 5 providers behind one router β offline
stub, local Ollama, and hosted Anthropic, OpenAI, Gemini (native raw-fetchadapters: streaming, tool calls, usage, cancellation). - Bounded agent loop β multi-step tool execution with a step ceiling, idle timeout, and operator cancellation.
- Safe tools β read-only filesystem tools always on;
write_file/run_shellare deny-by-default, approval-gated, and run in an OS sandbox (no network, writes confined to the project root). - Safety β secret redaction and prompt-injection awareness on untrusted tool output.
- Observability β full per-run traces and token/cost accounting, queryable from the CLI, TUI, and web dashboard.
- Storage β SQLite (WAL) for sessions, messages, and runs.
- Tested β
node:testsuite + a deterministic eval gate that runs on every change.
See the roadmap for what's next (robustness, Slack, memory).
The agent loop is the easy part now. It's a commoditized afternoon of work. The real cost β and the real risk β lives in channel integrations, the safety guardrails around tool execution, and the maintenance tail of a non-deterministic system.
- A useful single-channel version is a weekend.
- Matching OpenClaw's breadth and robustness is months.
- That gap is almost entirely robustness, not features.
This project is sequenced by dependency and risk, not perceived difficulty β because the cheap-looking parts aren't where projects like this fail.
flowchart TD
channels["channels<br/>(CLI, HTTP, Slack, ...)"] --> ingress["ingress<br/>normalize to internal Message"]
ingress --> loop["agent loop"]
loop <--> router["model router"]
router --> providers["Claude / OpenAI / Gemini / Ollama"]
loop --> tools["tool runtime"]
tools <--> sandbox["sandbox"]
loop --> state[("SQLite state<br/>sessions Β· messages Β· summaries<br/>tool logs Β· traces")]
| Area | Reality |
|---|---|
| Agentic loop | Easy happy path (~1 day); the robust version β cancellation, retries, streaming, mid-loop context management β is where the hard things converge. |
| Model routing | Built as native raw-fetch adapters (no SDK/LiteLLM). Chat shapes normalize; tool-call/streaming behavior doesn't β each provider needed its own translation. |
| Tool / skill system | Moderate. Registry is easy; sandboxing is the real design cost (don't let a hallucinated rm -rf ruin your week). |
| Memory / context | Moderate. Session history is trivial; durable memory is an open problem β accept the ~60β80% ceiling and move on. |
| Channel integrations | The expensive part. Each channel = its own auth, rate limits, formatting, delivery, idempotency. This is the months. |
Load-bearing for a non-deterministic system that touches real resources:
- Evals β highest hidden cost; decide the strategy before the loop.
- Observability β full per-run traces + token/cost accounting.
- Prompt injection & safety β untrusted channel content reaches the tool executor.
- SQLite write contention β WAL + a write queue.
| Milestone | Scope | Status |
|---|---|---|
| M0 | Eval skeleton β representative tasks + runner | β |
| M1 | Weekend agent β loop + routing + tools + one channel + sessions | β |
| M2 | Safe tools β sandbox, deny-by-default destructive ops, logging | β |
| M3 | Robust loop β cancellation, streaming, context management | π§ cancellation + streaming done; retries (#14) and large-output/context mgmt (#16) remain |
| M4 | Channels β prove the adapter abstraction | π§ CLI + TUI + Web shipped; Slack (#18/#19) remains |
| M5 | Memory + observability β tracing, cost, retrieval | π§ tracing + cost + dashboards done; cross-session memory (#21) remains |
Also pending: SQLite write-queue for concurrency (#17). Tracked on the project board and issues.
The core (CLI, agent loop, router, eval harness) runs on Node β₯ 22 built-ins
alone β no install required. The TUI (Ink + React) and web UI (Vite +
React + Tailwind + Recharts) pull in dependencies via npm install; both are
lazy-loaded so the core CLI stays install-free.
Install the meridian command once (symlinks the local CLI onto your PATH):
npm linkThen set your provider so you don't pass it every time. Config is read from
./.env (project-local) and ~/.config/meridian/.env (user-level), with
real environment variables overriding both. Use the user-level file if you run
the global meridian command from outside the project directory:
cp .env.example .env # project-local, or:
mkdir -p ~/.config/meridian && cp .env.example ~/.config/meridian/.env
# edit it: MERIDIAN_PROVIDER=ollama, MERIDIAN_MODEL=qwen2.5:7b# Talk to the assistant (CLI is the first channel β #7).
# With no .env, the offline `stub` provider echoes input β no API keys needed.
meridian "say hello"
echo "piped request" | meridian
# With .env set to Ollama, it runs a real multi-step loop and can use read-only
# tools (read_file, list_dir) confined to the project root.
meridian "list the files in eval/tasks and tell me how many there are"
# Override config per-run inline (wins over .env):
MERIDIAN_PROVIDER=ollama MERIDIAN_MODEL=qwen2.5:7b meridian "summarize the PRD goals"
# Hosted providers (#4): set the key + select the provider/model (see .env.example).
# anthropic β ANTHROPIC_API_KEY, MERIDIAN_MODEL=claude-opus-4-8
# openai β OPENAI_API_KEY, MERIDIAN_MODEL=gpt-4o (OPENAI_BASE_URL for compatible APIs)
# gemini β GEMINI_API_KEY, MERIDIAN_MODEL=gemini-1.5-flash
# Run the eval suite (gates every change, PRD Β§9) and the unit tests.
npm run eval
npm test
# Inspect what the agent actually did (#8): every model call, tool call
# (with inputs/outputs/timing), token usage, and the final reply.
meridian trace # list recent runs
meridian trace <id> # show one run's full trace (id prefix is enough)
# Token & cost totals per session (#20). Local models are free; hosted costs
# are estimates from a pricing snapshot.
meridian cost
# Or just ask the agent β it has a read-only token_usage tool:
meridian "how many tokens have I used?"
# Interactive TUI (#28) β a live chat shell. Unlike the one-shot CLI above,
# this needs dependencies (Ink + React), so run `npm install` first:
npm install && meridian tui # or: npm run tuiThe TUI is lazy-loaded β meridian "...", trace, and cost still run on
Node built-ins with no install. Only meridian tui pulls in Ink.
Inside the TUI: replies stream live and render as markdown (#37), tool
calls show as they run, and a status bar tracks model/tokens/cost. Keys:
Up/Down recall input history, \ + Enter adds a newline (Enter submits),
Esc cancels an in-flight run, Ctrl-C quits. Guarded tools
(write_file/run_shell) prompt for approval ([y]es / [n]o / [a]lways).
Slash commands (#34): /help, /model [name] and /provider [name] (switch
live), /tools, /tokens (session usage + cost), /new (fresh session),
/clear, /quit.
npm run web:build # build the Vite/React/Tailwind frontend β web/dist
meridian serve # http://127.0.0.1:8787, prints a bearer tokenOpen the printed URL for the web UI:
- Chat (#74) β a streaming conversation with markdown replies; runs are
recorded so they show up in the dashboard. Shows live tool activity as
calls run (#75), a stop button to cancel an in-flight run (#77), and a
controls bar to switch model / start a new session live (#78). Guarded
tools (
write_file/run_shell) prompt for allow/deny in the browser before running (#76), then execute sandboxed. - Dashboard (#71/#72) β runs list + click-through trace view, session totals,
and interactive Recharts charts (#86): cost-over-time, tokens-per-day, and
tokens-by-model, over the data in
meridian.db. (Recharts is lazy-loaded, so the chat view stays light.)
The header has a light/dark theme toggle (#87) β it defaults to your OS preference and remembers your choice.
For frontend dev with hot reload: npm run web:dev (proxies the API to a running
meridian serve).
Bound to localhost with bearer-token auth β every /api/* request needs
the token (/health is open; the served page injects the token for the SPA).
Prefer not to npm link? Everything also works via
node --disable-warning=ExperimentalWarning src/cli/index.js "..." (the flag
silences the one-off node:sqlite experimental notice; the meridian bin and
npm start already include it).
Guarded tools (write_file, run_shell) are denied by default (#9) β not
even shown to the model unless you opt in. Two ways to allow them:
- Statically (works everywhere, incl. the one-shot CLI):
MERIDIAN_ALLOW_TOOLS=write_file,run_shellorMERIDIAN_ALLOW_WRITES=1. - Interactively in the TUI (#32): guarded tools are offered to the model, and when it requests one the TUI prompts you
[y]es / [n]o / [a]lwaysbefore it runs β no env flags needed.
When allowed, run_shell runs inside an OS sandbox (#10): no network, and
writes confined to the project root (macOS sandbox-exec; it refuses to run
unsandboxed on platforms without support yet). Read tools are always available.
Secrets are redacted from tool output before it reaches the model or the session
history (#12): the literal values of secret-looking env vars plus common token
shapes (OpenAI/GitHub/AWS/etc.) become [REDACTED]. Best-effort defense in
depth, not a guarantee. See .env.example.
| Path | Role |
|---|---|
src/cli/ Β· src/ingress/ |
First channel: CLI entry + ingress (#7) |
src/agent/ |
Agent loop β bounded multi-step tool execution (#3) |
src/router/ |
Model router β stub, Ollama, Anthropic, OpenAI, and Gemini providers (#4) |
src/tools/ |
Tool registry + read-only filesystem tools (#5) |
src/store/ |
SQLite session state (#6) |
src/core/ |
Shared Message/Reply types + config |
eval/ |
Eval harness + task suite (#2) |
Working across three channels (CLI, TUI, Web) with five model providers, sandboxed tools, approvals, full tracing, and cost accounting. The remaining work is robustness and reach β retries, context management, a SQLite write-queue, Slack, and cross-session memory (see the roadmap). Tracked on the project board and issues.
Licensed under the Apache License 2.0 β see LICENSE.