@mdrv/zellijmcp
v0.1.5
Published
Agent-owned zellij sessions over MCP — no plugin, no main-session pollution.
Maintainers
Readme
zellijmcp
Agent-owned zellij sessions over the Model Context Protocol.
Lets AI agents (OpenCode, Claude Code, Cursor, Zed, GitHub Copilot, mcphost, etc.) spin up and drive their own detached zellij sessions — without ever loading a plugin into the human user's interactive session, without permission prompts, without focus stealing.
The agent gets a workspace of tabs and panes it fully controls; the human's session is never modified.
Why this exists
Every other zellij-MCP bridge loads a .wasm plugin into the user's currently-focused interactive session — the one the human is using. That means:
- A first-run permission prompt the user must babysit
- Side effects on live panes (focus stealing, layout churn, plugin pane appearing)
- Coupling between "what the agent is doing" and "where the human is working"
zellijmcp sidesteps all of it by giving the agent its own sessions, created detached via zellij attach --create-background. The agent's tabs/panes/output live in a session the human never has to see.
Install
bun add -g @mdrv/zellijmcp
# or
npm i -g @mdrv/zellijmcpRequires:
Verify:
zellijmcp --version # zellijmcp v0.1.0
zellijmcp doctor # checks zellij, paths, and default sessionQuick start
Run the MCP server on stdio (this is what agent hosts spawn):
zellijmcpConfigure your agent host
OpenCode (opencode.jsonc):
{
"mcp": {
"zellij": {
"type": "local",
"command": ["zellijmcp"],
"environment": {}
}
}
}Claude Desktop / Cursor / Cline (claude_desktop_config.json):
{
"mcpServers": {
"zellij": {
"command": "zellijmcp",
"args": [],
"env": {}
}
}
}That's it. On first tool call, zellijmcp auto-creates a detached session named mcp in the background and the agent starts working in it.
Concepts an agent should know
Reading this section is enough to use
zellijmcpproductively. Full reference is indocs/.
Sessions
Every tool call targets a session. Resolution order:
- The tool's
sessionparameter (if provided) --session/-sCLI flag, orZELLIJ_MCP_SESSION_NAMEenv var- Default:
mcp(ormcp-<pid>with--pid-scopefor concurrent-safe runs)
Sessions are agent-owned: tracked in ~/.zellijmcp/sessions.json. zellij_kill_session refuses to kill sessions not in the registry (unless force: true) — defense against touching the human's session.
To run parallel workspaces, give each one a name:
zellij_create_session({ name: 'build' })
zellij_create_session({ name: 'tests' })
zellij_run_command({ session: 'build', command: ['bun', 'run', 'b'] })
zellij_run_command({ session: 'tests', command: ['bun', 'test'] })Tabs & panes
A session has tabs; each tab has panes. Pane ids are ephemeral per session — capture the return value of zellij_new_pane / zellij_run_command (they return { paneId }).
Shell
New panes launch bash by default, not the user's interactive shell (e.g. nushell). Override per-call with the shell parameter, or globally via --shell/$ZELLIJ_MCP_SHELL.
Errors
Tool failures return { isError: true, content: [{ type: 'text', text: '[KIND] message' }] } — they never throw to the host. Stable kinds include [SESSION_NOT_FOUND], [SESSION_NAME_INVALID], [PANE_NOT_FOUND], [ZELLIJ_NONZERO], [TIMEOUT]. Read the bracketed prefix and self-correct.
Tool reference (30 tools)
Every tool accepts an optional session: string. Read-only tools carry readOnlyHint; mutating tools carry destructiveHint or openWorldHint.
Session (6)
| Tool | Purpose |
| ----------------------- | ------------------------------------------------------ |
| zellij_list_sessions | Read-only. All zellij sessions visible to this user. |
| zellij_create_session | Create a detached background session. Idempotent. |
| zellij_kill_session | Kill one session. Registry-gated unless force: true. |
| zellij_session_info | Read-only. Tab/pane/client counts for a session. |
| zellij_rename_session | Rename. |
| zellij_dump_layout | Read-only. Current layout as KDL text. |
Tabs (8)
| Tool | Purpose |
| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| zellij_list_tabs | Read-only. JSON tab list. |
| zellij_new_tab | Create a tab (name?, layout?, cwd?). |
| zellij_go_to_tab | Focus by tabId, name, or index. |
| zellij_rename_tab / zellij_close_tab / zellij_move_tab / zellij_tab_info / zellij_toggle_fullscreen_tab | As named. |
Panes (10)
| Tool | Purpose |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| zellij_list_panes | Read-only. JSON panes (with tabId? filter). |
| zellij_new_pane | Open a pane (direction?, floating?, name?, cwd?, shell?). Returns paneId. |
| zellij_run_command | Spawn a command in a new pane (command: string[], cwd?, name?, floating?, closeOnExit?, tabId?). Returns paneId. |
| zellij_close_pane / zellij_focus_pane / zellij_rename_pane / zellij_resize_pane / zellij_move_pane / zellij_toggle_floating_panes / zellij_toggle_fullscreen / zellij_stack_panes | As named. |
Terminal I/O (5)
| Tool | Purpose |
| -------------------- | --------------------------------------------------------------------------------------------- |
| zellij_write_chars | Send text to a pane (no newline). |
| zellij_send_keys | Send named keys: ['Enter'], ['Ctrl-c'], ['Escape'], ['Tab'], ['Up'], ['F1'], etc. |
| zellij_read_pane | Capture pane viewport (full: true for scrollback, ansi: true for escapes). |
| zellij_stream_pane | Subscribe to a pane for durationMs (default 2000, max 30000). Returns frames. |
| zellij_run_command | (See Panes — it spawns + returns paneId.) |
Editor (1)
| Tool | Purpose |
| ------------------ | ------------------------------------------------------------------- |
| zellij_edit_file | Open a file in $EDITOR inside a pane (paneId, path, line?). |
Common workflows
"Run this command and tell me what happened"
const { paneId } = await zellij_run_command({
command: ['cargo', 'test', '--', '--nocapture'],
cwd: '/path/to/repo',
name: 'cargo-test',
})
const out = await zellij_read_pane({ paneId, full: true })"Run a long-running server and tail it"
const { paneId } = await zellij_run_command({
command: ['bun', 'run', 'dev'],
cwd: '/path/to/app',
name: 'dev-server',
})
// Now poll:
const viewport = await zellij_read_pane({ paneId })
// Or stream 5 seconds of output:
const { frames } = await zellij_stream_pane({ paneId, durationMs: 5000 })"Drive an interactive TUI (lazygit, btop, claude)"
const { paneId } = await zellij_run_command({ command: ['lazygit'], cwd: '/repo' })
// Send keystrokes:
await zellij_send_keys({ paneId, keys: ['Enter'] })
await zellij_send_keys({ paneId, keys: ['q'] }) // quit
const final = await zellij_read_pane({ paneId, ansi: true })"Multi-session workspace"
await zellij_create_session({ name: 'frontend' })
await zellij_create_session({ name: 'backend' })
await zellij_run_command({ session: 'frontend', command: ['bun', 'run', 'dev'], cwd: '/app/ui' })
await zellij_run_command({ session: 'backend', command: ['go', 'run', '.'], cwd: '/app/api' })
// Later:
const sessions = await zellij_list_sessions()"Use nushell for one specific pane"
const { paneId } = await zellij_new_pane({ shell: 'nu' })CLI
zellijmcp is a crustjs CLI. Bare invocation runs the MCP server. Subcommands exist for out-of-band inspection:
zellijmcp # run MCP server (default)
zellijmcp -h # help
zellijmcp -V # version
zellijmcp session list # list all zellij sessions
zellijmcp session list --mine # only registry-tracked sessions
zellijmcp session kill <name> # kill one (refuses untracked unless --force)
zellijmcp session kill-all # kill everything we created
zellijmcp doctor # diagnostic check
# Common global flags (inherited by subcommands):
# -s, --session <NAME> Default target session (default: mcp)
# -S, --shell <PATH> Shell for new panes (default: bash)
# --pid-scope Use mcp-<pid> as default session name
# -d, --debug Verbose logging (stderr only; stdout is JSON-RPC)
# --trace Trace logging
# --log-file [<dir>] Tee logs to ~/.zellijmcp/logs/zellijmcp-<pid>.log
# --json Emit JSON from subcommands (where supported)Environment variables
| Var | Purpose | Default |
| ------------------------------- | ------------------------------------ | -------------------------------------------- |
| ZELLIJ_MCP_BIN | Path to zellij binary | zellij (PATH lookup) |
| ZELLIJ_MCP_SESSION_NAME | Default session name | mcp |
| ZELLIJ_MCP_SHELL | Shell for new panes/sessions | bash |
| ZELLIJ_MCP_NO_ENSURE_SESSION | Skip boot-time session creation | 0 |
| ZELLIJ_MCP_KILL_ON_SHUTDOWN | Kill registry sessions on MCP exit | 0 |
| ZELLIJ_MCP_LOG_DIR | Log directory | ~/.zellijmcp/logs/ |
| ZELLIJ_MCP_REGISTRY | Registry path | ~/.zellijmcp/sessions.json |
| ZELLIJ_MCP_SESSION_BLOCKLIST | Colon-separated extra blocked names | (includes main and $ZELLIJ_SESSION_NAME) |
| ZELLIJ_MCP_ALLOW_USER_SESSION | Let the agent see the user's session | 0 |
How it works (under the hood)
No plugins, no daemons, no IPC. Every tool call is one or two zellij CLI invocations with --session <name>:
zellij --session mcp action list-panes --json→ typed JSONzellij --session mcp action dump-screen --pane-id 3→ pane viewport as textzellij --session mcp run --cwd /repo --name build -- make test→ new pane, returnsterminal_43zellij --session mcp subscribe --pane-id 3 --format json→ pane render frames
Stdout of zellijmcp is reserved for JSON-RPC; all logging goes to stderr (or a file) so it can't corrupt the protocol. The agent's session is addressed exclusively via --session, so the human's session is never touched.
Full internals: docs/01-architecture.md.
Project layout
zellijmcp/
├── src/
│ ├── cli/ crustjs entry + commands + handlers
│ ├── mcp/ McpServer setup + safe-tool wrapper + 30 tools
│ ├── zellij/ spawn + parse + high-level client
│ ├── session/ registry + lifecycle + default-name resolution
│ ├── config.ts env + flag → ResolvedConfig
│ ├── errors.ts typed discriminated-union errors
│ ├── log.ts logtape (stderr-only)
│ └── index.ts public API
├── test/ 145 bun tests + fixtures/fake-zellij.sh
├── docs/ 9 design + reference docs
└── .github/workflows/ ci.yml + release.ymlDocumentation
docs/00-overview.md— What/why/how, roadmapdocs/01-architecture.md— Module layout, isolation modeldocs/02-cli-reference.md— Full CLI + env var reference, shell selectiondocs/03-mcp-tools.md— All 30 tools with Zod schemasdocs/04-zellij-cli-mapping.md— How each tool maps tozellijCLIdocs/05-session-model.md— Ownership, registry, safety railsdocs/06-inspired-by.md— Prior art analysisdocs/07-development.md— Repo layout, scripts, logging, testingdocs/08-release.md— GitHub Actions, NPM publish
Development
git clone https://github.com/mdrv/zellijmcp
cd zellijmcp
bun install
bun test # 145 tests
bun run typecheck # tsc --noEmit
bun run fc # dprint check
bun b # build to dist/License
MIT © Umar Alfarouk
