Skip to main content

Zenii Installation and Usage Guide

A complete guide to installing, running, and integrating Zenii across all supported platforms, interfaces, and programming languages.

Note: This document was generated with AI assistance and may contain inaccuracies. If you find errors, please report an issue.

Table of Contents


Overview

Zenii is an AI assistant platform with a Rust backend and multiple client interfaces. All interfaces communicate through a single HTTP+WebSocket gateway running on localhost:18981 by default.

Architecture:

Desktop (Tauri+Svelte)  ──┐
CLI (zenii) ──┼──▶ Gateway (axum) ──▶ AI Providers (OpenAI, Anthropic, etc.)
TUI (zenii-tui) ──┤ :18981 SQLite DB
Daemon (headless) ──┤ Memory Store
Your App (HTTP/WS) ──┘ Tool Registry

Interfaces

InterfaceBinaryUse CaseRequires Display
Desktopzenii-desktopFull GUI experience with Svelte frontendYes
Daemonzenii-daemonHeadless server, Docker, systemd, APIsNo
CLIzeniiTerminal chat, scripting, pipingNo
TUIzenii-tuiInteractive terminal dashboardNo (terminal only)

The daemon is the core — it runs the gateway server. Desktop embeds the daemon internally. CLI and TUI connect to a running daemon over HTTP/WS.


Installation by Platform

Linux (x86_64)

Pre-built binary:

# Download the latest release
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-x86_64-unknown-linux-gnu.tar.gz
tar xzf zenii-x86_64-unknown-linux-gnu.tar.gz

# Install
sudo mv zenii-daemon zenii zenii-tui /usr/local/bin/

From source:

# Prerequisites
sudo apt install build-essential pkg-config libsqlite3-dev libssl-dev

# Clone and build
git clone https://github.com/sprklai/zenii.git
cd zenii
cargo build --release -p zenii-daemon -p zenii-cli -p zenii-tui

# Binaries are in target/release/
sudo cp target/release/zenii-daemon target/release/zenii target/release/zenii-tui /usr/local/bin/

Desktop app (Debian/Ubuntu):

# Download .deb package
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-desktop_amd64.deb
sudo dpkg -i zenii-desktop_amd64.deb

# Or AppImage (no install needed)
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-desktop_amd64.AppImage
chmod +x zenii-desktop_amd64.AppImage
./zenii-desktop_amd64.AppImage

Linux (ARM64 / Raspberry Pi)

# Pre-built binary
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-aarch64-unknown-linux-gnu.tar.gz
tar xzf zenii-aarch64-unknown-linux-gnu.tar.gz
sudo mv zenii-daemon zenii /usr/local/bin/

From source on the board:

sudo apt install build-essential pkg-config libsqlite3-dev libssl-dev
cargo build --release -p zenii-daemon -p zenii-cli

Cross-compile from x86 host:

# Using the build script
./scripts/build.sh --target linux-arm64 --release --crates "zenii-daemon zenii-cli"

# Or using Docker-based cross-compilation
./scripts/build.sh --target linux-arm64 --release --docker

Linux (ARMv7 / Older Boards)

# Pre-built binary
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-armv7-unknown-linux-gnueabihf.tar.gz
tar xzf zenii-armv7-unknown-linux-gnueabihf.tar.gz
sudo mv zenii-daemon zenii /usr/local/bin/

# Cross-compile from host
./scripts/build.sh --target linux-armv7 --release

macOS

Pre-built binary:

# Apple Silicon (M1/M2/M3/M4)
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-aarch64-apple-darwin.tar.gz
tar xzf zenii-aarch64-apple-darwin.tar.gz
sudo mv zenii-daemon zenii zenii-tui /usr/local/bin/

# Intel
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-x86_64-apple-darwin.tar.gz
tar xzf zenii-x86_64-apple-darwin.tar.gz
sudo mv zenii-daemon zenii zenii-tui /usr/local/bin/

Desktop app:

# Download .dmg
curl -LO https://github.com/sprklai/zenii/releases/latest/download/Zenii.dmg
open Zenii.dmg
# Drag to Applications

From source:

# Prerequisites (Homebrew)
brew install sqlite3 pkg-config

git clone https://github.com/sprklai/zenii.git
cd zenii
cargo build --release -p zenii-daemon -p zenii-cli -p zenii-tui

Windows

Pre-built binary:

# Download from GitHub Releases
Invoke-WebRequest -Uri "https://github.com/sprklai/zenii/releases/latest/download/zenii-x86_64-pc-windows-msvc.zip" -OutFile zenii.zip
Expand-Archive zenii.zip -DestinationPath C:\zenii
$env:Path += ";C:\zenii"

Desktop app:

Download the .msi or .exe (NSIS) installer from GitHub Releases and run it.

From source:

# Requires Visual Studio Build Tools + Rust
git clone https://github.com/sprklai/zenii.git
cd zenii
cargo build --release -p zenii-daemon -p zenii-cli

Docker / Docker Compose

Note: Pre-built Docker images are not currently published. Build from source instead.

Build from Source

git clone https://github.com/sprklai/zenii.git
cd zenii
docker build -t zenii .
docker run -d \
--name zenii \
-p 18981:18981 \
-v zenii-data:/data \
-e ZENII_TOKEN=your-secret-token \
-e RUST_LOG=info \
zenii

Docker Compose

A docker-compose.yml is provided in the repository root:

git clone https://github.com/sprklai/zenii.git
cd zenii
export ZENII_TOKEN=your-secret-token
docker compose up -d

Running Zenii

Desktop App (GUI)

The desktop app is a Tauri 2 application with an embedded Svelte frontend. It runs the daemon internally — no separate server process needed.

# Launch the desktop app
zenii-desktop

# Or from source during development
cd crates/zenii-desktop
cargo tauri dev

Features:

  • Full chat interface with streaming responses
  • Session management (create, switch, delete)
  • Memory browser with search
  • Settings UI (providers, credentials, persona, channels)
  • System tray with show/hide/quit
  • Close-to-tray behavior (quit via tray menu)

The desktop app exposes the same gateway on localhost:18981, so you can use the CLI or custom scripts alongside it. To connect to an external daemon instead of the embedded one, set ZENII_GATEWAY_URL=http://host:port.

Daemon (Headless Server)

The daemon runs the gateway without any UI. Use it for servers, Docker, systemd services, or as a backend for custom frontends.

# Start with defaults
zenii-daemon

# Start with custom config
zenii-daemon --config /path/to/config.toml

# Start with environment overrides
ZENII_TOKEN=secret RUST_LOG=debug zenii-daemon

As a systemd service (Linux):

# /etc/systemd/system/zenii.service
[Unit]
Description=Zenii AI Assistant Daemon
After=network.target

[Service]
Type=simple
User=zenii
ExecStart=/usr/local/bin/zenii-daemon
Restart=on-failure
RestartSec=5
Environment=RUST_LOG=info
Environment=ZENII_TOKEN=your-secret-token

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now zenii
sudo journalctl -u zenii -f

CLI (Command Line)

The CLI is a thin HTTP/WS client that connects to a running daemon. Start the daemon first (or use the desktop app).

# Start the daemon in the background
zenii-daemon &

# Interactive chat (WebSocket streaming)
zenii chat

# Chat with a specific model
zenii chat --model anthropic/claude-sonnet-4-20250514

# Single prompt (non-interactive)
zenii run "Summarize the Rust ownership model in 3 sentences"
zenii run "Continue our discussion" --session my-project --model gpt-4o

# Session management
zenii chat --session my-project

# Provider management
zenii provider list
zenii provider default openai gpt-4o
zenii key set openai sk-your-key

# Memory
zenii memory search "meeting notes"
zenii memory add "user-pref" "User prefers concise answers"
zenii memory remove "user-pref"

# Daemon management
zenii daemon start
zenii daemon stop
zenii daemon status

# Configuration
zenii config show
zenii config set log_level debug

# Scheduled jobs
zenii schedule list
zenii schedule create my-job --schedule-type interval --interval-secs 300
zenii schedule status

# Plugins
zenii plugin list
zenii plugin install github.com/user/weather-plugin
zenii plugin remove weather

# Channels
zenii channel list --source telegram
zenii channel messages <session-id>

# Embeddings
zenii embedding status
zenii embedding activate local

# Connect to a remote daemon
zenii --host 192.168.1.100 --port 18981 --token secret chat

TUI (Terminal UI)

Note: The TUI is planned but not yet implemented. It will be available in a future release.

# Start TUI (connects to running daemon)
zenii-tui

Configuration

Zenii looks for config.toml in platform-specific directories:

PlatformConfig Path
Linux~/.config/zenii/config.toml
macOS~/Library/Application Support/com.sprklai.zenii/config.toml
Windows%APPDATA%\sprklai\zenii\config\config.toml
Docker/config/config.toml (mount volume, pass --config /config/config.toml)

Essential config fields:

# Network
gateway_host = "127.0.0.1" # Use "0.0.0.0" for Docker/remote access
gateway_port = 18981

# Security
gateway_auth_token = "your-secret-token"

# AI Provider (defaults: anthropic / claude-sonnet-4-6)
provider_name = "openai" # openai, anthropic, groq, together, openrouter, local
provider_model_id = "gpt-4o"

# Logging
log_level = "info" # trace, debug, info, warn, error

# CORS (for browser frontends)
gateway_cors_origins = ["http://localhost:5173"]

See docs/configuration.md for the full field reference.


HTTP API Integration

Zenii exposes a REST API on port 18981. Any language with an HTTP client can integrate.

Base URL: http://localhost:18981

Interactive docs: Open http://localhost:18981/api-docs in a browser for the Scalar UI (OpenAPI explorer).

Authentication

All requests (except GET /health) require a bearer token if gateway_auth_token is set:

Authorization: Bearer <your-token>

Python

import requests

BASE = "http://localhost:18981"
HEADERS = {"Authorization": "Bearer your-token"}

# Health check
r = requests.get(f"{BASE}/health")
print(r.json()) # {"status": "ok"}

# Create a session
r = requests.post(f"{BASE}/sessions", headers=HEADERS,
json={"title": "Python session"})
session = r.json()
print(session["id"])

# Send a chat message (non-streaming)
r = requests.post(f"{BASE}/chat", headers=HEADERS, json={
"prompt": "What is the capital of France?",
"session_id": session["id"]
})
print(r.json()["response"])

# Search memory
r = requests.get(f"{BASE}/memory", headers=HEADERS,
params={"q": "meeting notes", "limit": 10})
for entry in r.json():
print(entry["content"][:100])

# Store a memory
requests.post(f"{BASE}/memory", headers=HEADERS, json={
"key": "user-preference",
"content": "User prefers concise answers",
"category": "Core"
})

# List providers
r = requests.get(f"{BASE}/providers", headers=HEADERS)
for p in r.json():
print(f"{p['id']}: {p['name']}")

# List available tools
r = requests.get(f"{BASE}/tools", headers=HEADERS)
for t in r.json():
print(f"{t['name']}: {t['description']}")

JavaScript / TypeScript

const BASE = "http://localhost:18981";
const headers = {
"Authorization": "Bearer your-token",
"Content-Type": "application/json",
};

// Health check
const health = await fetch(`${BASE}/health`).then(r => r.json());
console.log(health); // { status: "ok" }

// Create session
const session = await fetch(`${BASE}/sessions`, {
method: "POST",
headers,
body: JSON.stringify({ title: "JS session" }),
}).then(r => r.json());

// Chat
const chat = await fetch(`${BASE}/chat`, {
method: "POST",
headers,
body: JSON.stringify({
prompt: "Explain async/await in JavaScript",
session_id: session.id,
}),
}).then(r => r.json());
console.log(chat.response);

// Memory search
const memories = await fetch(
`${BASE}/memory?q=project+notes&limit=5`,
{ headers }
).then(r => r.json());

Go

package main

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)

const baseURL = "http://localhost:18981"
const token = "your-token"

func main() {
// Chat request
body, _ := json.Marshal(map[string]string{
"prompt": "What is Rust's ownership model?",
})

req, _ := http.NewRequest("POST", baseURL+"/chat", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()

var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
fmt.Println(result["response"])
}

Rust

use reqwest::Client;
use serde_json::{json, Value};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new();
let base = "http://localhost:18981";
let token = "your-token";

// Chat
let resp: Value = client
.post(format!("{base}/chat"))
.bearer_auth(token)
.json(&json!({
"prompt": "Explain Rust lifetimes"
}))
.send()
.await?
.json()
.await?;

println!("{}", resp["response"]);
Ok(())
}

Ruby

require 'net/http'
require 'json'
require 'uri'

BASE = "http://localhost:18981"
TOKEN = "your-token"

def zenii_request(method, path, body = nil)
uri = URI("#{BASE}#{path}")
http = Net::HTTP.new(uri.host, uri.port)

req = case method
when :get then Net::HTTP::Get.new(uri)
when :post then Net::HTTP::Post.new(uri)
end

req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = body.to_json if body

JSON.parse(http.request(req).body)
end

# Chat
result = zenii_request(:post, "/chat", { prompt: "Hello from Ruby!" })
puts result["response"]

# List sessions
sessions = zenii_request(:get, "/sessions")
sessions.each { |s| puts "#{s['id']}: #{s['title']}" }

Java / Kotlin

import java.net.URI;
import java.net.http.*;
import com.google.gson.*;

public class ZeniiClient {
static final String BASE = "http://localhost:18981";
static final String TOKEN = "your-token";
static final HttpClient client = HttpClient.newHttpClient();
static final Gson gson = new Gson();

public static void main(String[] args) throws Exception {
// Chat
var body = gson.toJson(java.util.Map.of(
"prompt", "Explain Java streams"
));

var request = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/chat"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();

var response = client.send(request, HttpResponse.BodyHandlers.ofString());
var result = gson.fromJson(response.body(), JsonObject.class);
System.out.println(result.get("response").getAsString());
}
}

C# / .NET

using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

var client = new HttpClient();
client.BaseAddress = new Uri("http://localhost:18981");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "your-token");

// Chat
var chatBody = new StringContent(
JsonSerializer.Serialize(new { prompt = "Hello from C#!" }),
Encoding.UTF8, "application/json");

var response = await client.PostAsync("/chat", chatBody);
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
Console.WriteLine(result.RootElement.GetProperty("response").GetString());

// Health check
var health = await client.GetStringAsync("/health");
Console.WriteLine(health);

Shell / cURL

TOKEN="your-token"
BASE="http://localhost:18981"

# Health check
curl $BASE/health

# Chat
curl -s -X POST $BASE/chat \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"prompt": "Hello from cURL!"}' | jq .response

# Create session
curl -s -X POST $BASE/sessions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Shell session"}' | jq .

# List tools
curl -s $BASE/tools -H "Authorization: Bearer $TOKEN" | jq '.[].name'

# Store memory
curl -s -X POST $BASE/memory \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"key": "work-note", "content": "Important note", "category": "Core"}'

# Search memory
curl -s "$BASE/memory?q=note&limit=5" \
-H "Authorization: Bearer $TOKEN" | jq .

# System info
curl -s $BASE/system/info -H "Authorization: Bearer $TOKEN" | jq .

WebSocket Streaming

For real-time streaming responses, connect via WebSocket. This is how the desktop app and CLI get token-by-token output.

Protocol

Endpoint: ws://localhost:18981/ws/chat?token=<auth_token>

Client sends:

{
"prompt": "Your message here",
"session_id": "optional-uuid",
"model": "optional-model-id"
}

Server sends (in order):

TypeFieldsDescription
textcontentStreaming token (partial response)
tool_callcall_id, tool_name, argsAgent is invoking a tool
tool_resultcall_id, tool_name, output, success, duration_msTool execution result
doneResponse complete
errorerrorError occurred

Notification endpoint (ws://localhost:18981/ws/notifications?token=<auth_token>) pushes real-time events:

TypeFieldsDescription
notificationevent_type, job_id, job_name, message, status, errorScheduler events
channel_messagechannel, sender, session_id, content_preview, roleIncoming channel messages

Python (websockets)

import asyncio
import json
import websockets

async def chat_stream(prompt: str):
uri = "ws://localhost:18981/ws/chat?token=your-token"

async with websockets.connect(uri) as ws:
await ws.send(json.dumps({"prompt": prompt}))

async for message in ws:
data = json.loads(message)

if data["type"] == "text":
print(data["content"], end="", flush=True)
elif data["type"] == "tool_call":
print(f"\n[Calling {data['tool_name']}...]")
elif data["type"] == "tool_result":
print(f"[Tool result: {data['output'][:100]}...]")
elif data["type"] == "done":
print("\n--- Done ---")
break
elif data["type"] == "error":
print(f"\nError: {data['error']}")
break

asyncio.run(chat_stream("Write a haiku about Rust"))

JavaScript / TypeScript (Browser)

function chatStream(prompt: string, token: string): void {
const ws = new WebSocket(
`ws://localhost:18981/ws/chat?token=${token}`
);

ws.onopen = () => {
ws.send(JSON.stringify({ prompt }));
};

ws.onmessage = (event) => {
const data = JSON.parse(event.data);

switch (data.type) {
case "text":
// Append to UI
document.getElementById("output")!.textContent += data.content;
break;
case "tool_call":
console.log(`Calling tool: ${data.tool_name}`);
break;
case "tool_result":
console.log(`Tool result: ${data.output}`);
break;
case "done":
console.log("Stream complete");
ws.close();
break;
case "error":
console.error(`Error: ${data.error}`);
ws.close();
break;
}
};
}

JavaScript / TypeScript (Node.js)

import WebSocket from "ws";

function chatStream(prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(
"ws://localhost:18981/ws/chat?token=your-token"
);
let fullResponse = "";

ws.on("open", () => {
ws.send(JSON.stringify({ prompt }));
});

ws.on("message", (raw: Buffer) => {
const data = JSON.parse(raw.toString());

if (data.type === "text") {
fullResponse += data.content;
process.stdout.write(data.content);
} else if (data.type === "done") {
ws.close();
resolve(fullResponse);
} else if (data.type === "error") {
ws.close();
reject(new Error(data.error));
}
});
});
}

const response = await chatStream("Explain WebSockets");
console.log("\nFull response length:", response.length);

Go (gorilla/websocket)

package main

import (
"encoding/json"
"fmt"
"log"
"net/url"

"github.com/gorilla/websocket"
)

type WSMessage struct {
Type string `json:"type"`
Content string `json:"content,omitempty"`
Error string `json:"error,omitempty"`
}

func main() {
u := url.URL{
Scheme: "ws",
Host: "localhost:18981",
Path: "/ws/chat",
RawQuery: "token=your-token",
}

conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
log.Fatal(err)
}
defer conn.Close()

// Send prompt
conn.WriteJSON(map[string]string{
"prompt": "Explain Go concurrency",
})

// Read streaming response
for {
_, raw, err := conn.ReadMessage()
if err != nil {
break
}

var msg WSMessage
json.Unmarshal(raw, &msg)

switch msg.Type {
case "text":
fmt.Print(msg.Content)
case "done":
fmt.Println("\n--- Done ---")
return
case "error":
log.Fatalf("Error: %s", msg.Error)
}
}
}

Rust (tokio-tungstenite)

use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio_tungstenite::connect_async;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let url = "ws://localhost:18981/ws/chat?token=your-token";
let (mut ws, _) = connect_async(url).await?;

// Send prompt
let msg = json!({"prompt": "Explain async Rust"}).to_string();
ws.send(msg.into()).await?;

// Read streaming response
while let Some(Ok(msg)) = ws.next().await {
if let Ok(text) = msg.to_text() {
let data: Value = serde_json::from_str(text)?;
match data["type"].as_str() {
Some("text") => print!("{}", data["content"].as_str().unwrap_or("")),
Some("done") => {
println!("\n--- Done ---");
break;
}
Some("error") => {
eprintln!("Error: {}", data["error"]);
break;
}
_ => {}
}
}
}

Ok(())
}

Linux Single-Board Computers

Zenii runs well on ARM-based Linux boards as a headless daemon.

Raspberry Pi 4/5 (ARM64)

Recommended setup — 4GB+ RAM, 64-bit Raspberry Pi OS.

# Install pre-built ARM64 binary
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-aarch64-unknown-linux-gnu.tar.gz
tar xzf zenii-aarch64-unknown-linux-gnu.tar.gz
sudo mv zenii-daemon zenii /usr/local/bin/

# Create config
mkdir -p ~/.config/zenii
cat > ~/.config/zenii/config.toml <<'EOF'
gateway_host = "0.0.0.0"
gateway_port = 18981
provider_name = "openai"
provider_model_id = "gpt-4o-mini"
log_level = "info"
gateway_auth_token = "your-secret-token"
EOF

# Start as systemd service
sudo cp zenii.service /etc/systemd/system/
sudo systemctl enable --now zenii

Raspberry Pi 3 / Zero 2W (ARMv7)

Limited RAM (512MB-1GB). Use minimal features.

# ARMv7 binary
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-armv7-unknown-linux-gnueabihf.tar.gz
tar xzf zenii-armv7-unknown-linux-gnueabihf.tar.gz
sudo mv zenii-daemon /usr/local/bin/

# Build from source without optional features (smaller binary)
cargo build --release -p zenii-daemon --no-default-features

NVIDIA Jetson

Jetson Nano/Xavier/Orin run Ubuntu ARM64. Use the standard ARM64 binary:

curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-aarch64-unknown-linux-gnu.tar.gz
tar xzf zenii-aarch64-unknown-linux-gnu.tar.gz
sudo mv zenii-daemon zenii /usr/local/bin/

If you want to use local embeddings (fastembed), build with the local-embeddings feature:

cargo build --release -p zenii-daemon --features local-embeddings

Orange Pi / Rock Pi / Pine64

Most modern ARM64 SBCs running Armbian or Ubuntu work with the ARM64 binary. For older ARMv7 boards, use the ARMv7 build.

# Check your architecture
uname -m
# aarch64 → use ARM64 binary
# armv7l → use ARMv7 binary

Performance Tuning for Low-Resource Boards

For boards with limited RAM (< 2GB), adjust config.toml:

# Use a lightweight model
provider_model_id = "gpt-4o-mini"

# Reduce memory/DB overhead
log_level = "warn"

# Disable optional features if building from source
# Build with: --no-default-features --features gateway,ai

Docker on ARM boards:

# Docker Compose works on Raspberry Pi with 64-bit OS
# The Dockerfile multi-stage build handles ARM64 natively
docker compose up -d

Docker Compose Recipes

Basic

The default docker-compose.yml included in the repo:

services:
zenii:
build: .
ports:
- "18981:18981"
volumes:
- ./config:/config:ro
- zenii-data:/data
environment:
- ZENII_TOKEN=${ZENII_TOKEN:-}
- RUST_LOG=info
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:18981/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped

volumes:
zenii-data:

With Reverse Proxy (Caddy)

Expose Zenii over HTTPS with automatic TLS:

services:
zenii:
build: .
volumes:
- ./config:/config:ro
- zenii-data:/data
environment:
- ZENII_TOKEN=${ZENII_TOKEN}
- RUST_LOG=info
restart: unless-stopped
# No ports exposed — Caddy handles external access

caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
restart: unless-stopped

volumes:
zenii-data:
caddy-data:

Caddyfile:

zenii.example.com {
reverse_proxy zenii:18981
}

Multi-Instance

Run separate Zenii instances for different use cases:

services:
zenii-work:
build: .
ports:
- "18981:18981"
volumes:
- ./config/work:/config:ro
- work-data:/data
environment:
- ZENII_TOKEN=${WORK_TOKEN}
restart: unless-stopped

zenii-personal:
build: .
ports:
- "18982:18981"
volumes:
- ./config/personal:/config:ro
- personal-data:/data
environment:
- ZENII_TOKEN=${PERSONAL_TOKEN}
restart: unless-stopped

volumes:
work-data:
personal-data:

Cloud and Virtual Machine Deployment

Zenii runs on any Linux VM, container service, or VPS. The daemon is a single statically-linked binary with an embedded SQLite database — no external database or message queue required. This makes it straightforward to deploy anywhere.

Minimum requirements:

  • 1 vCPU, 512MB RAM (lightweight usage)
  • 2 vCPU, 1GB RAM (recommended for concurrent users)
  • 1GB disk (binary + database + config)

AWS (EC2)

Launch an EC2 instance and run Zenii as a systemd service.

# 1. Launch an EC2 instance (Amazon Linux 2023 or Ubuntu 24.04)
# - t3.micro (free tier eligible) or t3.small for production
# - Security group: allow inbound TCP 18981 (or 443 if using a reverse proxy)

# 2. SSH into the instance
ssh -i your-key.pem ec2-user@<instance-ip>

# 3. Install Zenii
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-x86_64-unknown-linux-gnu.tar.gz
tar xzf zenii-x86_64-unknown-linux-gnu.tar.gz
sudo mv zenii-daemon zenii /usr/local/bin/

# 4. Configure
mkdir -p ~/.config/zenii
cat > ~/.config/zenii/config.toml <<'EOF'
gateway_host = "0.0.0.0"
gateway_port = 18981
provider_name = "openai"
provider_model_id = "gpt-4o"
log_level = "info"
gateway_auth_token = "generate-a-strong-token-here"
EOF

# 5. Create systemd service
sudo tee /etc/systemd/system/zenii.service <<'EOF'
[Unit]
Description=Zenii AI Assistant
After=network.target

[Service]
Type=simple
User=ec2-user
ExecStart=/usr/local/bin/zenii-daemon
Restart=on-failure
RestartSec=5
Environment=RUST_LOG=info

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now zenii

# 6. Verify
curl http://localhost:18981/health

With an Application Load Balancer (HTTPS + WebSocket):

  • Create an ALB with HTTPS listener (ACM certificate)
  • Target group: instance on port 18981, health check path /health
  • Enable sticky sessions for WebSocket connections
  • Set idle timeout to 3600s for long-running WebSocket chats

Graviton (ARM64) for cost savings:

# Use a t4g.micro/small instance (ARM64 Graviton, ~20% cheaper)
# Download the ARM64 binary instead:
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-aarch64-unknown-linux-gnu.tar.gz

AWS (ECS Fargate)

Serverless container — no instance management.

# 1. Push image to ECR
aws ecr create-repository --repository-name zenii
aws ecr get-login-password | docker login --username AWS --password-stdin <account-id>.dkr.ecr.<region>.amazonaws.com

docker build -t zenii .
docker tag zenii:latest <account-id>.dkr.ecr.<region>.amazonaws.com/zenii:latest
docker push <account-id>.dkr.ecr.<region>.amazonaws.com/zenii:latest

Task definition (zenii-task.json):

{
"family": "zenii",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"containerDefinitions": [
{
"name": "zenii",
"image": "<account-id>.dkr.ecr.<region>.amazonaws.com/zenii:latest",
"portMappings": [
{ "containerPort": 18981, "protocol": "tcp" }
],
"environment": [
{ "name": "RUST_LOG", "value": "info" }
],
"secrets": [
{
"name": "ZENII_TOKEN",
"valueFrom": "arn:aws:secretsmanager:<region>:<account>:secret:zenii-token"
}
],
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:18981/health || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/zenii",
"awslogs-region": "<region>",
"awslogs-stream-prefix": "zenii"
}
}
}
]
}
# Register and run
aws ecs register-task-definition --cli-input-json file://zenii-task.json
aws ecs create-service \
--cluster default \
--service-name zenii \
--task-definition zenii \
--desired-count 1 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[subnet-xxx],securityGroups=[sg-xxx],assignPublicIp=ENABLED}"

Note: ECS Fargate tasks have ephemeral storage. For persistent data (SQLite DB, memories), mount an EFS volume or use a task with EBS volume support.

AWS (Lightsail)

The simplest AWS option — fixed monthly pricing.

# 1. Create a Lightsail instance ($3.50/mo for 512MB, $5/mo for 1GB)
# - Choose Ubuntu 24.04 or Amazon Linux 2023
# - Open port 18981 in Networking tab

# 2. SSH in and follow the same steps as EC2 above
ssh ubuntu@<lightsail-ip>
# ... install binary, configure, create systemd service

Google Cloud (GCE)

# 1. Create a VM
gcloud compute instances create zenii-vm \
--zone=us-central1-a \
--machine-type=e2-micro \
--image-family=ubuntu-2404-lts-amd64 \
--image-project=ubuntu-os-cloud \
--tags=zenii

# 2. Open firewall
gcloud compute firewall-rules create zenii-allow \
--allow=tcp:18981 \
--target-tags=zenii

# 3. SSH and install
gcloud compute ssh zenii-vm --zone=us-central1-a
# ... install binary, configure, create systemd service (same as EC2)

Google Cloud (Cloud Run)

Fully managed, scales to zero when idle.

# 1. Build and push to Artifact Registry
gcloud artifacts repositories create zenii --repository-format=docker --location=us-central1
gcloud builds submit --tag us-central1-docker.pkg.dev/<project>/zenii/zenii:latest

# 2. Deploy
gcloud run deploy zenii \
--image us-central1-docker.pkg.dev/<project>/zenii/zenii:latest \
--port 18981 \
--memory 1Gi \
--cpu 1 \
--min-instances 0 \
--max-instances 1 \
--set-env-vars "RUST_LOG=info" \
--set-secrets "ZENII_TOKEN=zenii-token:latest" \
--allow-unauthenticated

Caveat: Cloud Run has request timeouts (default 5 min, max 60 min). Long WebSocket sessions may be interrupted. Use --session-affinity and increase timeout for streaming chat.

Azure (VM)

# 1. Create a VM
az vm create \
--resource-group zenii-rg \
--name zenii-vm \
--image Ubuntu2404 \
--size Standard_B1s \
--admin-username azureuser \
--generate-ssh-keys

# 2. Open port
az vm open-port --resource-group zenii-rg --name zenii-vm --port 18981

# 3. SSH and install
ssh azureuser@<vm-ip>
# ... install binary, configure, create systemd service (same as EC2)

Azure (Container Instances)

Quick serverless containers — no cluster needed.

# 1. Create container registry and push image
az acr create --resource-group zenii-rg --name zeniiregistry --sku Basic
az acr build --registry zeniiregistry --image zenii:latest .

# 2. Deploy container
az container create \
--resource-group zenii-rg \
--name zenii \
--image zeniiregistry.azurecr.io/zenii:latest \
--cpu 1 \
--memory 1 \
--ports 18981 \
--environment-variables RUST_LOG=info \
--secure-environment-variables ZENII_TOKEN=your-secret-token \
--ip-address Public

DigitalOcean

# 1. Create a droplet ($4/mo for 512MB, $6/mo for 1GB)
doctl compute droplet create zenii \
--region nyc3 \
--size s-1vcpu-512mb-10gb \
--image ubuntu-24-04-x64 \
--ssh-keys <your-key-fingerprint>

# 2. SSH and install
ssh root@<droplet-ip>
# ... install binary, configure, create systemd service

# Or use DigitalOcean App Platform with Docker:
# Push your repo, set Dockerfile path, expose port 18981

Hetzner

Excellent value — ARM64 CAX servers start at ~$4/mo.

# 1. Create a server via CLI or console
# - CAX11 (ARM64, 2 vCPU, 4GB RAM, ~$4/mo) — great value
# - CX22 (x86, 2 vCPU, 4GB RAM, ~$4/mo)

# 2. SSH and install
ssh root@<server-ip>

# For ARM64 (CAX):
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-aarch64-unknown-linux-gnu.tar.gz

# For x86 (CX):
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-x86_64-unknown-linux-gnu.tar.gz

tar xzf zenii-*.tar.gz
mv zenii-daemon zenii /usr/local/bin/

# ... configure and create systemd service

Linode / Akamai

# 1. Create a Linode ($5/mo Nanode for 1GB)
linode-cli linodes create \
--type g6-nanode-1 \
--region us-east \
--image linode/ubuntu24.04 \
--root_pass <password> \
--label zenii

# 2. SSH and install
ssh root@<linode-ip>
# ... install binary, configure, create systemd service

Oracle Cloud (Free Tier)

Oracle offers always-free ARM64 instances — up to 4 OCPU and 24GB RAM.

# 1. Create an Always Free Ampere (ARM64) instance
# - Shape: VM.Standard.A1.Flex (1-4 OCPU, 6-24GB RAM, free)
# - Image: Ubuntu 24.04

# 2. SSH and install ARM64 binary
ssh ubuntu@<instance-ip>
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-aarch64-unknown-linux-gnu.tar.gz
tar xzf zenii-aarch64-unknown-linux-gnu.tar.gz
sudo mv zenii-daemon zenii /usr/local/bin/

# 3. Open port in OCI security list
# Network > Virtual Cloud Networks > Security Lists > Add Ingress Rule
# Source: 0.0.0.0/0, Protocol: TCP, Port: 18981

# ... configure and create systemd service

Fly.io

Global edge deployment with persistent volumes.

Create fly.toml:

app = "zenii"
primary_region = "iad"

[build]
dockerfile = "Dockerfile"

[env]
RUST_LOG = "info"

[http_service]
internal_port = 18981
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0

[mounts]
source = "zenii_data"
destination = "/data"

[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
fly launch --no-deploy
fly secrets set ZENII_TOKEN=your-secret-token
fly volumes create zenii_data --size 1 --region iad
fly deploy

# Your app is at: https://zenii.fly.dev
curl https://zenii.fly.dev/health

Railway

Git-push-to-deploy with zero config.

# 1. Install Railway CLI
npm install -g @railway/cli

# 2. Login and init
railway login
railway init

# 3. Set variables
railway variables set ZENII_TOKEN=your-secret-token
railway variables set RUST_LOG=info

# 4. Deploy (auto-detects Dockerfile)
railway up

# Railway assigns a public URL automatically

Any Linux VM or VPS

This generic guide works for any provider (Vultr, Scaleway, OVH, Contabo, etc.):

# 1. SSH into your server
ssh user@<server-ip>

# 2. Download the binary (pick your architecture)
# x86_64:
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-x86_64-unknown-linux-gnu.tar.gz
# ARM64:
curl -LO https://github.com/sprklai/zenii/releases/latest/download/zenii-aarch64-unknown-linux-gnu.tar.gz

tar xzf zenii-*.tar.gz
sudo mv zenii-daemon zenii /usr/local/bin/

# 3. Configure
mkdir -p ~/.config/zenii
cat > ~/.config/zenii/config.toml <<'EOF'
gateway_host = "0.0.0.0"
gateway_port = 18981
provider_name = "openai"
provider_model_id = "gpt-4o"
log_level = "info"
gateway_auth_token = "$(openssl rand -hex 32)"
EOF

# 4. Create systemd service
sudo tee /etc/systemd/system/zenii.service <<'EOF'
[Unit]
Description=Zenii AI Assistant
After=network.target

[Service]
Type=simple
User=nobody
ExecStart=/usr/local/bin/zenii-daemon
Restart=on-failure
RestartSec=5
Environment=RUST_LOG=info

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now zenii

# 5. Verify
curl http://localhost:18981/health

# 6. (Optional) Set API keys via HTTP
curl -X POST http://localhost:18981/credentials \
-H "Authorization: Bearer <your-gateway-token>" \
-H "Content-Type: application/json" \
-d '{"key": "api_key:openai", "value": "sk-your-openai-key"}'

Or use Docker Compose on any VM:

# Install Docker
curl -fsSL https://get.docker.com | sh

# Clone and run
git clone https://github.com/sprklai/zenii.git
cd zenii
echo "ZENII_TOKEN=$(openssl rand -hex 32)" > .env
docker compose up -d

Security Hardening for Cloud

When exposing Zenii to the internet, follow these practices:

1. Always set an auth token:

gateway_auth_token = "use-a-long-random-string-here"

Generate one with: openssl rand -hex 32

2. Use a reverse proxy with TLS (never expose port 18981 directly):

# Caddy (auto-HTTPS)
sudo apt install caddy
cat > /etc/caddy/Caddyfile <<'EOF'
zenii.yourdomain.com {
reverse_proxy localhost:18981
}
EOF
sudo systemctl restart caddy

Or with nginx:

server {
listen 443 ssl;
server_name zenii.yourdomain.com;

ssl_certificate /etc/letsencrypt/live/zenii.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/zenii.yourdomain.com/privkey.pem;

location / {
proxy_pass http://localhost:18981;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
}

3. Firewall — only allow 80/443, block 18981 from public:

# UFW (Ubuntu)
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw deny 18981/tcp
sudo ufw enable

# Or iptables
sudo iptables -A INPUT -p tcp --dport 18981 -s 127.0.0.1 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 18981 -j DROP

4. Restrict CORS origins:

gateway_cors_origins = ["https://zenii.yourdomain.com"]

5. Run as a non-root user:

sudo useradd --system --no-create-home zenii
sudo chown -R zenii:zenii /home/zenii/.config/zenii /home/zenii/.local/share/zenii
# Update systemd service to use User=zenii

6. Regular backups:

# SQLite databases are in the data directory
# Back up daily with cron
0 2 * * * sqlite3 ~/.local/share/zenii/zenii.db ".backup /backups/zenii-$(date +\%Y\%m\%d).db"

Troubleshooting

Connection refused on port 18981:

  • Is the daemon running? zenii-daemon or docker compose ps
  • Check if another process uses the port: ss -tlnp | grep 18981
  • If using Docker, ensure gateway_host = "0.0.0.0" in config (not 127.0.0.1)

401 Unauthorized:

  • Set the token: Authorization: Bearer <token> header or ?token=<token> for WebSocket
  • Check gateway_auth_token in config matches what you're sending
  • GET /health bypasses auth — use it to verify the server is running

WebSocket disconnects immediately:

  • Verify the token is passed as query parameter: ws://host:18981/ws/chat?token=<token>
  • Check server logs: RUST_LOG=debug zenii-daemon

Docker: container exits immediately:

  • Check logs: docker compose logs zenii
  • Ensure config volume is mounted correctly
  • Verify config.toml syntax: valid TOML with correct field names

ARM build fails:

  • Install cross-compilation tools: sudo apt install gcc-aarch64-linux-gnu
  • Or use Docker-based cross-compilation: ./scripts/build.sh --target linux-arm64 --docker

Keyring not available (headless/Docker):

  • Zenii falls back to in-memory credential storage automatically
  • Set API keys via the HTTP API after startup:
    curl -X POST http://localhost:18981/credentials \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"key": "api_key:openai", "value": "sk-your-key"}'

OpenAPI docs not available at /api-docs:

  • Built without the api-docs feature. Rebuild with: cargo build -p zenii-daemon (enabled by default)