Skip to main content

Nix & NixOS Setup

Zaya Agent ships a Nix flake with three levels of integration:

LevelWho it's forWhat you get
nix run / nix profile installAny Nix user (macOS, Linux)Pre-built binary with all deps — then use the standard CLI workflow
NixOS module (native)NixOS server deploymentsDeclarative config, hardened systemd service, managed secrets
NixOS module (container)Agents that need self-modificationEverything above, plus a persistent Ubuntu container where the agent can apt/pip/npm install
What's different from the standard install

The curl | bash installer manages Python, Node, and dependencies itself. The Nix flake replaces all of that — every Python dependency is a Nix derivation built by uv2nix, and runtime tools (Node.js, git, ripgrep, ffmpeg) are wrapped into the binary's PATH. There is no runtime pip, no venv activation, no npm install.

For non-NixOS users, this only changes the install step. Everything after (zaya setup, zaya gateway install, config editing) works identically to the standard install.

For NixOS module users, the entire lifecycle is different: configuration lives in configuration.nix, secrets go through sops-nix/agenix, the service is a systemd unit, and CLI config commands are blocked. You manage zaya the same way you manage any other NixOS service.

Prerequisites

  • Nix with flakes enabledDeterminate Nix recommended (enables flakes by default)
  • API keys for the services you want to use (at minimum: an OpenRouter or Anthropic key)

Quick Start (Any Nix User)

No clone needed. Nix fetches, builds, and runs everything:

# Run directly (builds on first use, cached after)
nix run github:uzziz/zaya-agent -- setup
nix run github:uzziz/zaya-agent -- chat

# Or install persistently
nix profile install github:uzziz/zaya-agent
zaya setup
zaya chat

After nix profile install, zaya, zaya-agent, and zaya-acp are on your PATH. From here, the workflow is identical to the standard installationzaya setup walks you through provider selection, zaya gateway install sets up a launchd (macOS) or systemd user service, and config lives in ~/.zaya/.

Building from a local clone
git clone https://github.com/uzziz/zaya-agent.git
cd zaya-agent
nix build
./result/bin/zaya setup

NixOS Module

The flake exports nixosModules.default — a full NixOS service module that declaratively manages user creation, directories, config generation, secrets, documents, and service lifecycle.

note

This module requires NixOS. For non-NixOS systems (macOS, other Linux distros), use nix profile install and the standard CLI workflow above.

Add the Flake Input

# /etc/nixos/flake.nix (or your system flake)
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
zaya-agent.url = "github:uzziz/zaya-agent";
};

outputs = { nixpkgs, zaya-agent, ... }: {
nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
zaya-agent.nixosModules.default
./configuration.nix
];
};
};
}

Minimal Configuration

# configuration.nix
{ config, ... }: {
services.zaya-agent = {
enable = true;
settings.model.default = "anthropic/claude-sonnet-4";
environmentFiles = [ config.sops.secrets."zaya-env".path ];
addToSystemPackages = true;
};
}

That's it. nixos-rebuild switch creates the zaya user, generates config.yaml, wires up secrets, and starts the gateway — a long-running service that connects the agent to messaging platforms (Telegram, Discord, etc.) and listens for incoming messages.

Secrets are required

The environmentFiles line above assumes you have sops-nix or agenix configured. The file should contain at least one LLM provider key (e.g., OPENROUTER_API_KEY=sk-or-...). See Secrets Management for full setup. If you don't have a secrets manager yet, you can use a plain file as a starting point — just ensure it's not world-readable:

echo "OPENROUTER_API_KEY=sk-or-your-key" | sudo install -m 0600 -o zaya /dev/stdin /var/lib/zaya/env
services.zaya-agent.environmentFiles = [ "/var/lib/zaya/env" ];
addToSystemPackages

Setting addToSystemPackages = true does two things: puts the zaya CLI on your system PATH and sets ZAYA_HOME system-wide so the interactive CLI shares state (sessions, skills, cron) with the gateway service. Without it, running zaya in your shell creates a separate ~/.zaya/ directory.

Container-aware CLI

When container.enable = true and addToSystemPackages = true, every zaya command on the host automatically routes into the managed container. This means your interactive CLI session runs inside the same environment as the gateway service — with access to all container-installed packages and tools.

  • The routing is transparent: zaya chat, zaya sessions list, zaya version, etc. all exec into the container under the hood
  • All CLI flags are forwarded as-is
  • If the container isn't running, the CLI retries briefly (5s with a spinner for interactive use, 10s silently for scripts) then fails with a clear error — no silent fallback
  • For developers working on the zaya codebase, set ZAYA_DEV=1 to bypass container routing and run the local checkout directly

Set container.hostUsers to create a ~/.zaya symlink to the service state directory, so the host CLI and the container share sessions, config, and memories:

services.zaya-agent = {
container.enable = true;
container.hostUsers = [ "your-username" ];
addToSystemPackages = true;
};

Users listed in hostUsers are automatically added to the zaya group for file permission access.

Podman users: The NixOS service runs the container as root. Docker users get access via the docker group socket, but Podman's rootful containers require sudo. Grant passwordless sudo for your container runtime:

security.sudo.extraRules = [{
users = [ "your-username" ];
commands = [{
command = "/run/current-system/sw/bin/podman";
options = [ "NOPASSWD" ];
}];
}];

The CLI auto-detects when sudo is needed and uses it transparently. Without this, you'll need to run sudo zaya chat manually.

Verify It Works

After nixos-rebuild switch, check that the service is running:

# Check service status
systemctl status zaya-agent

# Watch logs (Ctrl+C to stop)
journalctl -u zaya-agent -f

# If addToSystemPackages is true, test the CLI
zaya version
zaya config # shows the generated config

Choosing a Deployment Mode

The module supports two modes, controlled by container.enable:

Native (default)Container
How it runsHardened systemd service on the hostPersistent Ubuntu container with /nix/store bind-mounted
SecurityNoNewPrivileges, ProtectSystem=strict, PrivateTmpContainer isolation, runs as unprivileged user inside
Agent can self-install packagesNo — only tools on the Nix-provided PATHYes — apt, pip, npm installs persist across restarts
Config surfaceSameSame
When to chooseStandard deployments, maximum security, reproducibilityAgent needs runtime package installation, mutable environment, experimental tools

To enable container mode, add one line:

{
services.zaya-agent = {
enable = true;
container.enable = true;
# ... rest of config is identical
};
}
info

Container mode auto-enables virtualisation.docker.enable via mkDefault. If you use Podman instead, set container.backend = "podman" and virtualisation.docker.enable = false.


Configuration

Declarative Settings

The settings option accepts an arbitrary attrset that is rendered as config.yaml. It supports deep merging across multiple module definitions (via lib.recursiveUpdate), so you can split config across files:

# base.nix
services.zaya-agent.settings = {
model.default = "anthropic/claude-sonnet-4";
toolsets = [ "all" ];
terminal = { backend = "local"; timeout = 180; };
};

# personality.nix
services.zaya-agent.settings = {
display = { compact = false; personality = "kawaii"; };
memory = { memory_enabled = true; user_profile_enabled = true; };
};

Both are deep-merged at evaluation time. Nix-declared keys always win over keys in an existing config.yaml on disk, but user-added keys that Nix doesn't touch are preserved. This means if the agent or a manual edit adds keys like skills.disabled or streaming.enabled, they survive nixos-rebuild switch.

Model naming

settings.model.default uses the model identifier your provider expects. With OpenRouter (the default), these look like "anthropic/claude-sonnet-4" or "google/gemini-3-flash". If you're using a provider directly (Anthropic, OpenAI), set settings.model.base_url to point at their API and use their native model IDs (e.g., "claude-sonnet-4-20250514"). When no base_url is set, Zaya defaults to OpenRouter.

Discovering available config keys

Run nix build .#configKeys && cat result to see every leaf config key extracted from Python's DEFAULT_CONFIG. You can paste your existing config.yaml into the settings attrset — the structure maps 1:1.

Full example: all commonly customized settings
{ config, ... }: {
services.zaya-agent = {
enable = true;
container.enable = true;

# ── Model ──────────────────────────────────────────────────────────
settings = {
model = {
base_url = "https://openrouter.ai/api/v1";
default = "anthropic/claude-opus-4.6";
};
toolsets = [ "all" ];
max_turns = 100;
terminal = { backend = "local"; cwd = "."; timeout = 180; };
compression = {
enabled = true;
threshold = 0.85;
summary_model = "google/gemini-3-flash-preview";
};
memory = { memory_enabled = true; user_profile_enabled = true; };
display = { compact = false; personality = "kawaii"; };
agent = { max_turns = 60; verbose = false; };
};

# ── Secrets ────────────────────────────────────────────────────────
environmentFiles = [ config.sops.secrets."zaya-env".path ];

# ── Documents ──────────────────────────────────────────────────────
documents = {
"SOUL.md" = builtins.readFile /home/user/.zaya/SOUL.md;
"USER.md" = ./documents/USER.md;
};

# ── MCP Servers ────────────────────────────────────────────────────
mcpServers.filesystem = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/data/workspace" ];
};

# ── Container options ──────────────────────────────────────────────
container = {
image = "ubuntu:24.04";
backend = "docker";
hostUsers = [ "your-username" ];
extraVolumes = [ "/home/user/projects:/projects:rw" ];
extraOptions = [ "--gpus" "all" ];
};

# ── Service tuning ─────────────────────────────────────────────────
addToSystemPackages = true;
extraArgs = [ "--verbose" ];
restart = "always";
restartSec = 5;
};
}

Escape Hatch: Bring Your Own Config

If you'd rather manage config.yaml entirely outside Nix, use configFile:

services.zaya-agent.configFile = /etc/zaya/config.yaml;

This bypasses settings entirely — no merge, no generation. The file is copied as-is to $ZAYA_HOME/config.yaml on each activation.

Customization Cheatsheet

Quick reference for the most common things Nix users want to customize:

I want to...OptionExample
Change the LLM modelsettings.model.default"anthropic/claude-sonnet-4"
Use a different provider endpointsettings.model.base_url"https://openrouter.ai/api/v1"
Add API keysenvironmentFiles[ config.sops.secrets."zaya-env".path ]
Give the agent a personalitydocuments."SOUL.md"builtins.readFile ./my-soul.md
Add MCP tool serversmcpServers.<name>See MCP Servers
Mount host directories into containercontainer.extraVolumes[ "/data:/data:rw" ]
Pass GPU access to containercontainer.extraOptions[ "--gpus" "all" ]
Use Podman instead of Dockercontainer.backend"podman"
Share state between host CLI and containercontainer.hostUsers[ "sidbin" ]
Add tools to the service PATH (native only)extraPackages[ pkgs.pandoc pkgs.imagemagick ]
Use a custom base imagecontainer.image"ubuntu:24.04"
Override the zaya packagepackageinputs.zaya-agent.packages.${system}.default.override { ... }
Change state directorystateDir"/opt/zaya"
Set the agent's working directoryworkingDirectory"/home/user/projects"

Secrets Management

Never put API keys in settings or environment

Values in Nix expressions end up in /nix/store, which is world-readable. Always use environmentFiles with a secrets manager.

Both environment (non-secret vars) and environmentFiles (secret files) are merged into $ZAYA_HOME/.env at activation time (nixos-rebuild switch). Zaya reads this file on every startup, so changes take effect with a systemctl restart zaya-agent — no container recreation needed.

sops-nix

{
sops = {
defaultSopsFile = ./secrets/zaya.yaml;
age.keyFile = "/home/user/.config/sops/age/keys.txt";
secrets."zaya-env" = { format = "yaml"; };
};

services.zaya-agent.environmentFiles = [
config.sops.secrets."zaya-env".path
];
}

The secrets file contains key-value pairs:

# secrets/zaya.yaml (encrypted with sops)
zaya-env: |
OPENROUTER_API_KEY=sk-or-...
TELEGRAM_BOT_TOKEN=123456:ABC...
ANTHROPIC_API_KEY=sk-ant-...

agenix

{
age.secrets.zaya-env.file = ./secrets/zaya-env.age;

services.zaya-agent.environmentFiles = [
config.age.secrets.zaya-env.path
];
}

OAuth / Auth Seeding

For platforms requiring OAuth (e.g., Discord), use authFile to seed credentials on first deploy:

{
services.zaya-agent = {
authFile = config.sops.secrets."zaya/auth.json".path;
# authFileForceOverwrite = true; # overwrite on every activation
};
}

The file is only copied if auth.json doesn't already exist (unless authFileForceOverwrite = true). Runtime OAuth token refreshes are written to the state directory and preserved across rebuilds.


Documents

The documents option installs files into the agent's working directory (the workingDirectory, which the agent reads as its workspace). Zaya looks for specific filenames by convention:

  • SOUL.md — the agent's system prompt / personality. Zaya reads this on startup and uses it as persistent instructions that shape its behavior across all conversations.
  • USER.md — context about the user the agent is interacting with.
  • Any other files you place here are visible to the agent as workspace files.
{
services.zaya-agent.documents = {
"SOUL.md" = ''
You are a helpful research assistant specializing in NixOS packaging.
Always cite sources and prefer reproducible solutions.
'';
"USER.md" = ./documents/USER.md; # path reference, copied from Nix store
};
}

Values can be inline strings or path references. Files are installed on every nixos-rebuild switch.


MCP Servers

The mcpServers option declaratively configures MCP (Model Context Protocol) servers. Each server uses either stdio (local command) or HTTP (remote URL) transport.

Stdio Transport (Local Servers)

{
services.zaya-agent.mcpServers = {
filesystem = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/data/workspace" ];
};
github = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-github" ];
env.GITHUB_PERSONAL_ACCESS_TOKEN = "\${GITHUB_TOKEN}"; # resolved from .env
};
};
}
tip

Environment variables in env values are resolved from $ZAYA_HOME/.env at runtime. Use environmentFiles to inject secrets — never put tokens directly in Nix config.

HTTP Transport (Remote Servers)

{
services.zaya-agent.mcpServers.remote-api = {
url = "https://mcp.example.com/v1/mcp";
headers.Authorization = "Bearer \${MCP_REMOTE_API_KEY}";
timeout = 180;
};
}

HTTP Transport with OAuth

Set auth = "oauth" for servers using OAuth 2.1. Zaya implements the full PKCE flow — metadata discovery, dynamic client registration, token exchange, and automatic refresh.

{
services.zaya-agent.mcpServers.my-oauth-server = {
url = "https://mcp.example.com/mcp";
auth = "oauth";
};
}

Tokens are stored in $ZAYA_HOME/mcp-tokens/<server-name>.json and persist across restarts and rebuilds.

Initial OAuth authorization on headless servers

The first OAuth authorization requires a browser-based consent flow. In a headless deployment, Zaya prints the authorization URL to stdout/logs instead of opening a browser.

Option A: Interactive bootstrap — run the flow once via docker exec (container) or sudo -u zaya (native):

# Container mode
docker exec -it zaya-agent \
zaya mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth

# Native mode
sudo -u zaya ZAYA_HOME=/var/lib/zaya/.zaya \
zaya mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth

The container uses --network=host, so the OAuth callback listener on 127.0.0.1 is reachable from the host browser.

Option B: Pre-seed tokens — complete the flow on a workstation, then copy tokens:

zaya mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
scp ~/.zaya/mcp-tokens/my-oauth-server{,.client}.json \
server:/var/lib/zaya/.zaya/mcp-tokens/
# Ensure: chown zaya:zaya, chmod 0600

Sampling (Server-Initiated LLM Requests)

Some MCP servers can request LLM completions from the agent:

{
services.zaya-agent.mcpServers.analysis = {
command = "npx";
args = [ "-y" "analysis-server" ];
sampling = {
enabled = true;
model = "google/gemini-3-flash";
max_tokens_cap = 4096;
timeout = 30;
max_rpm = 10;
};
};
}

Managed Mode

When zaya runs via the NixOS module, the following CLI commands are blocked with a descriptive error pointing you to configuration.nix:

Blocked commandWhy
zaya setupConfig is declarative — edit settings in your Nix config
zaya config editConfig is generated from settings
zaya config set <key> <value>Config is generated from settings
zaya gateway installThe systemd service is managed by NixOS
zaya gateway uninstallThe systemd service is managed by NixOS

This prevents drift between what Nix declares and what's on disk. Detection uses two signals:

  1. ZAYA_MANAGED=true environment variable — set by the systemd service, visible to the gateway process
  2. .managed marker file in ZAYA_HOME — set by the activation script, visible to interactive shells (e.g., docker exec -it zaya-agent zaya config set ... is also blocked)

To change configuration, edit your Nix config and run sudo nixos-rebuild switch.


Container Architecture

info

This section is only relevant if you're using container.enable = true. Skip it for native mode deployments.

When container mode is enabled, zaya runs inside a persistent Ubuntu container with the Nix-built binary bind-mounted read-only from the host:

Host                                    Container
──── ─────────
/nix/store/...-zaya-agent-0.1.0 ──► /nix/store/... (ro)
~/.zaya -> /var/lib/zaya/.zaya (symlink bridge, per hostUsers)
/var/lib/zaya/ ──► /data/ (rw)
├── current-package -> /nix/store/... (symlink, updated each rebuild)
├── .gc-root -> /nix/store/... (prevents nix-collect-garbage)
├── .container-identity (sha256 hash, triggers recreation)
├── .zaya/ (ZAYA_HOME)
│ ├── .env (merged from environment + environmentFiles)
│ ├── config.yaml (Nix-generated, deep-merged by activation)
│ ├── .managed (marker file)
│ ├── .container-mode (routing metadata: backend, exec_user, etc.)
│ ├── state.db, sessions/, memories/ (runtime state)
│ └── mcp-tokens/ (OAuth tokens for MCP servers)
├── home/ ──► /home/zaya (rw)
└── workspace/ (MESSAGING_CWD)
├── SOUL.md (from documents option)
└── (agent-created files)

Container writable layer (apt/pip/npm): /usr, /usr/local, /tmp

The Nix-built binary works inside the Ubuntu container because /nix/store is bind-mounted — it brings its own interpreter and all dependencies, so there's no reliance on the container's system libraries. The container entrypoint resolves through a current-package symlink: /data/current-package/bin/zaya gateway run --replace. On nixos-rebuild switch, only the symlink is updated — the container keeps running.

What Persists Across What

EventContainer recreated?/data (state)/home/zayaWritable layer (apt/pip/npm)
systemctl restart zaya-agentNoPersistsPersistsPersists
nixos-rebuild switch (code change)No (symlink updated)PersistsPersistsPersists
Host rebootNoPersistsPersistsPersists
nix-collect-garbageNo (GC root)PersistsPersistsPersists
Image change (container.image)YesPersistsPersistsLost
Volume/options changeYesPersistsPersistsLost
environment/environmentFiles changeNoPersistsPersistsPersists

The container is only recreated when its identity hash changes. The hash covers: schema version, image, extraVolumes, extraOptions, and the entrypoint script. Changes to environment variables, settings, documents, or the zaya package itself do not trigger recreation.

Writable layer loss

When the identity hash changes (image upgrade, new volumes, new container options), the container is destroyed and recreated from a fresh pull of container.image. Any apt install, pip install, or npm install packages in the writable layer are lost. State in /data and /home/zaya is preserved (these are bind mounts).

If the agent relies on specific packages, consider baking them into a custom image (container.image = "my-registry/zaya-base:latest") or scripting their installation in the agent's SOUL.md.

GC Root Protection

The preStart script creates a GC root at ${stateDir}/.gc-root pointing to the current zaya package. This prevents nix-collect-garbage from removing the running binary. If the GC root somehow breaks, restarting the service recreates it.


Development

Dev Shell

The flake provides a development shell with Python 3.11, uv, Node.js, and all runtime tools:

cd zaya-agent
nix develop

# Shell provides:
# - Python 3.11 + uv (deps installed into .venv on first entry)
# - Node.js 20, ripgrep, git, openssh, ffmpeg on PATH
# - Stamp-file optimization: re-entry is near-instant if deps haven't changed

zaya setup
zaya chat

The included .envrc activates the dev shell automatically:

cd zaya-agent
direnv allow # one-time
# Subsequent entries are near-instant (stamp file skips dep install)

Flake Checks

The flake includes build-time verification that runs in CI and locally:

# Run all checks
nix flake check

# Individual checks
nix build .#checks.x86_64-linux.package-contents # binaries exist + version
nix build .#checks.x86_64-linux.entry-points-sync # pyproject.toml ↔ Nix package sync
nix build .#checks.x86_64-linux.cli-commands # gateway/config subcommands
nix build .#checks.x86_64-linux.managed-guard # ZAYA_MANAGED blocks mutation
nix build .#checks.x86_64-linux.bundled-skills # skills present in package
nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves user keys
What each check verifies
CheckWhat it tests
package-contentszaya and zaya-agent binaries exist and zaya version runs
entry-points-syncEvery [project.scripts] entry in pyproject.toml has a wrapped binary in the Nix package
cli-commandszaya --help exposes gateway and config subcommands
managed-guardZAYA_MANAGED=true zaya config set ... prints the NixOS error
bundled-skillsSkills directory exists, contains SKILL.md files, ZAYA_BUNDLED_SKILLS is set in wrapper
config-roundtrip7 merge scenarios: fresh install, Nix override, user key preservation, mixed merge, MCP additive merge, nested deep merge, idempotency

Options Reference

Core

OptionTypeDefaultDescription
enableboolfalseEnable the zaya-agent service
packagepackagezaya-agentThe zaya-agent package to use
userstr"zaya"System user
groupstr"zaya"System group
createUserbooltrueAuto-create user/group
stateDirstr"/var/lib/zaya"State directory (ZAYA_HOME parent)
workingDirectorystr"${stateDir}/workspace"Agent working directory (MESSAGING_CWD)
addToSystemPackagesboolfalseAdd zaya CLI to system PATH and set ZAYA_HOME system-wide

Configuration

OptionTypeDefaultDescription
settingsattrs (deep-merged){}Declarative config rendered as config.yaml. Supports arbitrary nesting; multiple definitions are merged via lib.recursiveUpdate
configFilenull or pathnullPath to an existing config.yaml. Overrides settings entirely if set

Secrets & Environment

OptionTypeDefaultDescription
environmentFileslistOf str[]Paths to env files with secrets. Merged into $ZAYA_HOME/.env at activation time
environmentattrsOf str{}Non-secret env vars. Visible in Nix store — do not put secrets here
authFilenull or pathnullOAuth credentials seed. Only copied on first deploy
authFileForceOverwriteboolfalseAlways overwrite auth.json from authFile on activation

Documents

OptionTypeDefaultDescription
documentsattrsOf (either str path){}Workspace files. Keys are filenames, values are inline strings or paths. Installed into workingDirectory on activation

MCP Servers

OptionTypeDefaultDescription
mcpServersattrsOf submodule{}MCP server definitions, merged into settings.mcp_servers
mcpServers.<name>.commandnull or strnullServer command (stdio transport)
mcpServers.<name>.argslistOf str[]Command arguments
mcpServers.<name>.envattrsOf str{}Environment variables for the server process
mcpServers.<name>.urlnull or strnullServer endpoint URL (HTTP/StreamableHTTP transport)
mcpServers.<name>.headersattrsOf str{}HTTP headers, e.g. Authorization
mcpServers.<name>.authnull or "oauth"nullAuthentication method. "oauth" enables OAuth 2.1 PKCE
mcpServers.<name>.enabledbooltrueEnable or disable this server
mcpServers.<name>.timeoutnull or intnullTool call timeout in seconds (default: 120)
mcpServers.<name>.connect_timeoutnull or intnullConnection timeout in seconds (default: 60)
mcpServers.<name>.toolsnull or submodulenullTool filtering (include/exclude lists)
mcpServers.<name>.samplingnull or submodulenullSampling config for server-initiated LLM requests

Service Behavior

OptionTypeDefaultDescription
extraArgslistOf str[]Extra args for zaya gateway
extraPackageslistOf package[]Extra packages on service PATH (native mode only)
restartstr"always"systemd Restart= policy
restartSecint5systemd RestartSec= value

Container

OptionTypeDefaultDescription
container.enableboolfalseEnable OCI container mode
container.backendenum ["docker" "podman"]"docker"Container runtime
container.imagestr"ubuntu:24.04"Base image (pulled at runtime)
container.extraVolumeslistOf str[]Extra volume mounts (host:container:mode)
container.extraOptionslistOf str[]Extra args passed to docker create
container.hostUserslistOf str[]Interactive users who get a ~/.zaya symlink to the service stateDir and are auto-added to the zaya group

Directory Layout

Native Mode

/var/lib/zaya/                     # stateDir (owned by zaya:zaya, 0750)
├── .zaya/ # ZAYA_HOME
│ ├── config.yaml # Nix-generated (deep-merged each rebuild)
│ ├── .managed # Marker: CLI config mutation blocked
│ ├── .env # Merged from environment + environmentFiles
│ ├── auth.json # OAuth credentials (seeded, then self-managed)
│ ├── gateway.pid
│ ├── state.db
│ ├── mcp-tokens/ # OAuth tokens for MCP servers
│ ├── sessions/
│ ├── memories/
│ ├── skills/
│ ├── cron/
│ └── logs/
├── home/ # Agent HOME
└── workspace/ # MESSAGING_CWD
├── SOUL.md # From documents option
└── (agent-created files)

Container Mode

Same layout, mounted into the container:

Container pathHost pathModeNotes
/nix/store/nix/storeroZaya binary + all Nix deps
/data/var/lib/zayarwAll state, config, workspace
/home/zaya${stateDir}/homerwPersistent agent home — pip install --user, tool caches
/usr, /usr/local, /tmp(writable layer)rwapt/pip/npm installs — persists across restarts, lost on recreation

Updating

# Update the flake input
nix flake update zaya-agent --flake /etc/nixos

# Rebuild
sudo nixos-rebuild switch

In container mode, the current-package symlink is updated and the agent picks up the new binary on restart. No container recreation, no loss of installed packages.


Troubleshooting

Podman users

All docker commands below work the same with podman. Substitute accordingly if you set container.backend = "podman".

Service Logs

# Both modes use the same systemd unit
journalctl -u zaya-agent -f

# Container mode: also available directly
docker logs -f zaya-agent

Container Inspection

systemctl status zaya-agent
docker ps -a --filter name=zaya-agent
docker inspect zaya-agent --format='{{.State.Status}}'
docker exec -it zaya-agent bash
docker exec zaya-agent readlink /data/current-package
docker exec zaya-agent cat /data/.container-identity

Force Container Recreation

If you need to reset the writable layer (fresh Ubuntu):

sudo systemctl stop zaya-agent
docker rm -f zaya-agent
sudo rm /var/lib/zaya/.container-identity
sudo systemctl start zaya-agent

Verify Secrets Are Loaded

If the agent starts but can't authenticate with the LLM provider, check that the .env file was merged correctly:

# Native mode
sudo -u zaya cat /var/lib/zaya/.zaya/.env

# Container mode
docker exec zaya-agent cat /data/.zaya/.env

GC Root Verification

nix-store --query --roots $(docker exec zaya-agent readlink /data/current-package)

Common Issues

SymptomCauseFix
Cannot save configuration: managed by NixOSCLI guards activeEdit configuration.nix and nixos-rebuild switch
Container recreated unexpectedlyextraVolumes, extraOptions, or image changedExpected — writable layer resets. Reinstall packages or use a custom image
zaya version shows old versionContainer not restartedsystemctl restart zaya-agent
Permission denied on /var/lib/zayaState dir is 0750 zaya:zayaUse docker exec or sudo -u zaya
nix-collect-garbage removed zayaGC root missingRestart the service (preStart recreates the GC root)
no container with name or ID "zaya-agent" (Podman)Podman rootful container not visible to regular userAdd passwordless sudo for podman (see Container-aware CLI section)
unable to find user zayaContainer still starting (entrypoint hasn't created user yet)Wait a few seconds and retry — the CLI retries automatically