Getting Started with Quilltap

There are, as with most things worth doing, several paths to the same destination. We have arranged them in order of increasing difficulty, rather like a cocktail menu that begins with champagne and ends with absinthe.

A Frank Word About Your Options

We would very much like to tell you that Quilltap installs with a single click and a wisp of lavender-scented magic. We nearly can. The desktop application now ships with its own Node.js runtime, so in its simplest form you really do just download it, open it, and go. The other paths require you to install something first, but once the machinery is assembled, the setup wizard handles the rest. The nouveau riche will understand… you can't buy the house of your dreams, you have to wait until a team of contractors builds it for you.

The choice between them comes down to two questions: what are you willing to install? and how much do you trust the AI running on your machine? That second question deserves a moment of your attention.

On the Matter of Sandboxes

As AI models grow more capable — reading files, writing code, using tools — the question of where that code executes becomes rather important. A virtual machine is a genuine locked room: if an AI-generated script misbehaves, it misbehaves inside a contained environment with no access to your host system beyond what you've explicitly shared. Docker provides a similar boundary, though somewhat thinner. Running directly on your machine provides no boundary at all — the AI's code is your code, with your permissions, in your house, wearing your slippers, drinking your chamomile tea, and potentially leaving the back door open when it goes out to play.

For most people having a pleasant conversation with a fictional character, this distinction is academic — you likely will not let them do much more than send you a photograph or search the web or read your story. For anyone exploring agentic features — tool use, code execution, file manipulation — it is emphatically not. Choose accordingly.

The Civilized Way Desktop App — Direct The Fortified Way Desktop App — VM The Dockworker's Route Docker The Shortcut Node.js / npx
You must install Nothing extra — Electron bundles Node.js macOS: Xcode CLI Tools Windows: WSL2 Linux: Docker Engine Docker Desktop (Win/Mac) or Docker Engine (Linux) Node.js 22+
First launch Fastest — uses Electron's embedded Node.js, runs directly Slowest — downloads a VM image (~150 MB), boots a Linux guest Fast — pulls the container image, starts in seconds Fast — downloads app files, runs directly
Subsequent launches Near-instant A few seconds for the VM to wake Near-instant Near-instant
AI sandbox ✗ No isolation Runs with your permissions ✓ Full VM isolation AI code runs in a locked room ⚠ Container isolation Good boundary, not airtight ✗ No isolation Runs with your permissions
Native window Yes — Electron desktop app Yes — Electron desktop app Optional — Electron or browser Browser only
Multiple data dirs Yes — managed from splash screen Yes — managed from splash screen Yes — mount different volumes Yes — --data-dir flag
Best for Most users — fastest start, zero prerequisites Safety-conscious users who want a genuine sandbox Server deployments, Docker veterans, and Linux users Quick evaluation, developers, and the impatient

Our recommendation for most people is the desktop application in Direct mode — it's the fastest path from download to conversation, with zero prerequisites. The Electron app ships with its own Node.js runtime, so you don't need to install anything else. Just download, open, and go.

If you use AI tools that read and write files on your behalf and want a genuine sandbox around that behavior, switch to VM mode from the splash screen. The Electron app lets you toggle between Direct, VM, and Docker runtimes at any time — no commitment required.

One More Warning

You may wonder what harm an AI could do on your machine. It's not just the AI, although that is something. If you enable full tool use, it can run scripts on your machine. Maybe install its own software. Maybe plant something that phones home as soon as you put your credit card number into a site in a browser window. Additionally, Quilltap has a plugin system, and anybody can write a plugin. If you install a plugin, how do you know that the plugin isn't running something on your computer that you don't want it to run?

I have often assured people that told me they trusted me… they shouldn't. I am a stranger on the internet, and I have no business running code on your machine, at least not code that you didn't purposefully sign up for by clicking "Install" or dragging that file into your Applications folder.

If you already have Docker and would rather not wait for a VM to boot, the Dockworker's Route is a perfectly civilized alternative — and if you use the Electron app to start it, it even lets you switch between the runtimes from its splash screen, so you needn't commit to any one on day one.

The npx shortcut is exactly what it sounds like: a quick way in for developers who already have Node.js installed. Excellent for kicking the tires. Less excellent for leaving the AI unsupervised with your filesystem. If you start here and decide to stay, we'd gently suggest graduating to one of the sandboxed options when the novelty of speed wears off and the reality of agentic AI settles in.

The Civilized Way (Recommended)

The simplest and most delightful way to run Quilltap is to install the desktop application. In its default Direct mode, it bundles everything you need — including its own Node.js runtime — so there is nothing else to install. For those who want a sandbox, the app can also run its backend inside a lightweight Linux virtual machine or a Docker container, switchable from the splash screen at any time.

Your platform:
1

Download

Visit the GitHub Releases page and download the latest stable release for your platform:

macOS

Download the .dmg installer. Open it and drag Quilltap to your Applications folder.

In Direct mode (the default), you need nothing else — just launch and go. To use VM mode, Quilltap uses Lima with Apple's Virtualization.framework, which requires Xcode Command Line Tools — the app will offer to install them if they're missing.

Windows

Download and run the .exe installer. Follow the prompts.

In Direct mode (the default), you need nothing else — just launch and go. To use VM mode, Quilltap uses WSL2, which is built into Windows 10 and 11. If WSL2 isn't already enabled, run wsl --install in PowerShell as Administrator and restart your computer. The app checks for this on startup and will tell you plainly if something is amiss.

Linux

Download the .AppImage file, make it executable (chmod +x), and run it. Or install the .deb package: sudo dpkg -i quilltap_*.deb

In Direct mode, the app runs using its own embedded Node.js runtime — no prerequisites. The Linux desktop app can also use Docker Engine as its runtime backend, because Linux was already the sandbox and always knew it.

2

Launch

On first run, Quilltap will:

  1. Present a splash screen where you choose your data directory and runtime mode
  2. Start the backend (instant in Direct mode; a short wait for VM or Docker)
  3. Open your workspace in a native window

That's it. No configuration files, no environment variables, no incantations.

Runtime Modes

The splash screen lets you switch between three runtime modes at any time:

Direct — Uses Electron's embedded Node.js. Zero prerequisites, fastest startup. Runs with your user permissions (no sandbox). This is the default.

VM — macOS: Lima with Apple's Virtualization.framework (requires Xcode Command Line Tools). Windows: WSL2. Full sandbox isolation — AI code runs in a locked room.

Docker — Requires Docker Desktop or Docker Engine. Container-level isolation — good boundary, not airtight.

Tip: The desktop app lets you manage multiple data directories from its splash screen — one for work, one for fiction, one for experiments. Each remembers its own runtime mode and window position.

The Dockworker's Route (Docker)

If you prefer containers — or you're running a Linux server, or you simply enjoy the gentle hum of virtualization — Docker is a fine choice.

What You'll Need

Docker Desktop (Windows, macOS, or Linux) — or Docker Engine on Linux. That's it.

With the Desktop App

The Electron desktop app includes a Docker runtime toggle right on the splash screen. Install Docker Desktop, launch Quilltap, and switch the runtime from “Direct” to “Docker.” Same native window, different engine underneath.

Standalone (Browser)

Skip the Electron wrapper entirely and access Quilltap through your browser:

Terminal
docker run -d \
  --name quilltap \
  -p 3000:3000 \
  -e QUILLTAP_TIMEZONE=America/New_York \
  -v /path/to/your/data:/app/quilltap \
  foundry9/quilltap

Open http://localhost:3000 and the setup wizard will guide you through first-time configuration.

Timezone tip: Set QUILLTAP_TIMEZONE to your IANA timezone (e.g., America/New_York, Europe/London, Asia/Tokyo). Since 4.8 the container applies this to the process clock rather than merely to printed timestamps, so scheduled autonomous rooms fire at the hour you meant, daily token budgets roll over at your midnight, and “today” and “yesterday” in episodic recall mean what you think they mean. TZ works too, though QUILLTAP_TIMEZONE wins where the two disagree. The desktop app and the startup scripts detect it for you; a container started by hand with neither variable set still runs on UTC.

Document stores tip: A container sees only what was bound into it when it was made. Database-backed stores live in your data directory and ride along without being asked; filesystem and Obsidian stores point anywhere at all on the host, and unless they are bound in as well, every operation touching a real byte will fail — quietly, since a store’s folder listing comes from a cached index and looks perfectly healthy either way. Run npm run start:docker from a checkout and it enumerates your stores and binds each one for you; npx quilltap docs docker-mounts prints the same plan for a hand-assembled docker run. Binds are fixed at creation, so an existing container needs --recreate before a newly added store becomes visible. Windows hosts are the exception and are told so plainly: a Windows path cannot mean the same thing inside a Linux container.

Using the Startup Scripts

For the smoothest Docker experience, use the included startup scripts. They auto-detect your platform, set the correct data directory, and pass your host’s timezone through to the container. The PowerShell script additionally probes for Ollama and forwards its port if it finds one.

PowerShell
irm https://raw.githubusercontent.com/foundry-9/quilltap-server/refs/heads/main/scripts/start-quilltap.ps1 | iex
View the full script
<#
.SYNOPSIS
    Quilltap Docker startup script for Windows.

.DESCRIPTION
    Detects platform, sets sensible defaults, and starts the Quilltap container.

.PARAMETER DataDir
    Data directory on host. Default: $env:APPDATA\Quilltap

.PARAMETER Port
    Host port. Default: 3000

.PARAMETER Name
    Container name. Default: quilltap

.PARAMETER Tag
    Image tag. Default: latest

.PARAMETER RedirectPorts
    Comma-separated ports to forward to host (e.g., "11434,3030")

.PARAMETER ExtraEnv
    Extra environment variables as an array of "KEY=VALUE" strings

.PARAMETER RestartPolicy
    Docker restart policy. Default: unless-stopped

.PARAMETER NoAutoDetect
    Skip auto-detection of local services (Ollama, etc.)

.PARAMETER DryRun
    Print the docker command without running it

.NOTES
    The host's timezone is detected and passed to the container as
    QUILLTAP_TIMEZONE, so scheduled rooms, daily budget rollover, and
    "today"/"yesterday" recall follow your clock rather than UTC.
    Override with -ExtraEnv "QUILLTAP_TIMEZONE=Europe/Paris", or pin the
    container to UTC with -ExtraEnv "QUILLTAP_TIMEZONE=UTC".

.EXAMPLE
    .\scripts\start-quilltap.ps1

.EXAMPLE
    .\scripts\start-quilltap.ps1 -RedirectPorts "11434,3030"

.EXAMPLE
    .\scripts\start-quilltap.ps1 -DataDir "D:\quilltap-data" -Port 8080
#>

param(
    [string]$DataDir,
    [int]$Port = 3000,
    [string]$Name = "quilltap",
    [string]$Tag = "latest",
    [string]$RedirectPorts,
    [string[]]$ExtraEnv = @(),
    [string]$RestartPolicy = "unless-stopped",
    [switch]$NoAutoDetect,
    [switch]$DryRun
)

$Image = "foundry9/quilltap"

# Resolve this host's IANA timezone name (e.g. "America/Chicago"), or $null if
# it cannot be determined confidently.
#
# The Windows trap: [TimeZoneInfo]::Local.Id returns a *Windows* id there
# ("Central Standard Time"), which Node's ICU cannot resolve — it would fall
# back to UTC, the exact bug this is meant to avoid. .NET 6+ (PowerShell 7.2+)
# can convert; on macOS/Linux Local.Id is already IANA.
function Get-HostTimezone {
    # Every branch assigns rather than returns, so all of them — including the
    # env-var ones — funnel through the single validation at the bottom. An
    # early return here would let a stray TZ=CDT through unchecked.
    $tz = $null

    if ($env:QUILLTAP_TIMEZONE) {
        $tz = $env:QUILLTAP_TIMEZONE
    } elseif ($env:TZ) {
        $tz = $env:TZ
    } else {
        $localId = $null
        try { $localId = [System.TimeZoneInfo]::Local.Id } catch { }

        if ($localId -and ($localId -eq "UTC" -or $localId -match "/")) {
            # Already IANA — macOS/Linux, or a host that reports one.
            $tz = $localId
        } elseif ($localId) {
            # Windows id -> IANA, where the runtime supports it (.NET 6+).
            try {
                $iana = $null
                if ([System.TimeZoneInfo]::TryConvertWindowsIdToIanaId($localId, [ref]$iana) -and $iana) {
                    $tz = $iana
                }
            } catch {
                # Older runtime without the conversion API — fall through to node.
            }
        }

        if (-not $tz) {
            # Node's own ICU lookup: exactly the name the container will resolve.
            try {
                $nodeTz = (& node -p 'Intl.DateTimeFormat().resolvedOptions().timeZone' 2>$null)
                if ($nodeTz) { $tz = ([string]$nodeTz).Trim() }
            } catch { }
        }
    }

    # Only emit a plausible IANA name. Anything else ("CDT", "Central Standard
    # Time" on a runtime too old to convert) would be silently ignored by the
    # container's ICU and fall back to UTC — better to pass nothing than a lie.
    if ($tz -and ($tz -eq "UTC" -or $tz -match "/")) { return $tz }
    return $null
}

# Detect platform and set default data directory
if (-not $DataDir) {
    if ($env:QUILLTAP_DATA_DIR) {
        $DataDir = $env:QUILLTAP_DATA_DIR
    } elseif ($IsLinux) {
        $DataDir = Join-Path $HOME ".quilltap"
    } elseif ($IsMacOS) {
        $DataDir = Join-Path $HOME "Library/Application Support/Quilltap"
    } else {
        # Windows
        $DataDir = Join-Path $env:APPDATA "Quilltap"
    }
}

# Override from environment variables
if ($env:QUILLTAP_PORT -and $Port -eq 3000) { $Port = [int]$env:QUILLTAP_PORT }
if ($env:QUILLTAP_CONTAINER_NAME -and $Name -eq "quilltap") { $Name = $env:QUILLTAP_CONTAINER_NAME }
if ($env:QUILLTAP_IMAGE_TAG -and $Tag -eq "latest") { $Tag = $env:QUILLTAP_IMAGE_TAG }
if ($env:HOST_REDIRECT_PORTS -and -not $RedirectPorts) { $RedirectPorts = $env:HOST_REDIRECT_PORTS }

# Auto-detect local services
if (-not $NoAutoDetect) {
    $DetectedPorts = @()

    # Check for Ollama on port 11434.
    #
    # BeginConnect plus a bounded wait, not Connect(): the synchronous call has
    # no timeout, so on a host that silently drops the SYN rather than refusing
    # it — corporate firewalls, some VPN configurations — it blocks for the OS
    # default, roughly 21 seconds on Windows, before concluding what is for our
    # purposes just "no Ollama here." A service on localhost answers in
    # milliseconds; anything slower is a no.
    #
    # The finally also fixes a leak: the old form only reached Close() on the
    # success path, so every miss abandoned an undisposed socket.
    $ProbeTimeoutMs = 500
    $tcp = New-Object System.Net.Sockets.TcpClient
    try {
        $iar = $tcp.BeginConnect("localhost", 11434, $null, $null)
        if ($iar.AsyncWaitHandle.WaitOne($ProbeTimeoutMs, $false)) {
            # Finished inside the window. EndConnect throws if it was refused.
            $tcp.EndConnect($iar)
            Write-Host "Detected Ollama on port 11434"
            $DetectedPorts += "11434"
        }
        # Timed out — treat as absent rather than waiting on the OS default.
    } catch {
        # Not running, or actively refused.
    } finally {
        $tcp.Close()
    }

    # Merge detected ports with any explicitly specified
    if ($DetectedPorts.Count -gt 0) {
        $DetectedCsv = $DetectedPorts -join ","
        if ($RedirectPorts) {
            $RedirectPorts = "$RedirectPorts,$DetectedCsv"
        } else {
            $RedirectPorts = $DetectedCsv
        }
        # Deduplicate
        $RedirectPorts = (($RedirectPorts -split ",") | Sort-Object -Unique) -join ","
    }
}

# Create data directory if it doesn't exist
if (-not $DryRun) {
    if (-not (Test-Path $DataDir)) {
        New-Item -ItemType Directory -Path $DataDir -Force | Out-Null
    }
}

# Build docker run arguments
$DockerArgs = @(
    "run", "-d",
    "--name", $Name,
    "--restart", $RestartPolicy,
    "-p", "${Port}:3000",
    "-v", "${DataDir}:/app/quilltap"
)

# Pass the host-side data directory so the app can display it in the UI
$DockerArgs += @("-e", "QUILLTAP_HOST_DATA_DIR=$DataDir")

# Add host port forwarding if requested
if ($RedirectPorts) {
    $DockerArgs += @("-e", "HOST_REDIRECT_PORTS=$RedirectPorts")
    # Linux needs explicit host.docker.internal mapping
    if ($IsLinux) {
        $DockerArgs += @("--add-host=host.docker.internal:host-gateway")
    }
}

# Pass the host timezone through, unless the caller already supplied one via
# -ExtraEnv. An explicit value always wins; detection only fills the gap.
$TzExplicit = $false
foreach ($env_var in $ExtraEnv) {
    if ($env_var -like "QUILLTAP_TIMEZONE=*" -or $env_var -like "TZ=*") { $TzExplicit = $true }
}

$Timezone = $null
if (-not $TzExplicit) {
    $Timezone = Get-HostTimezone
    if ($Timezone) {
        $DockerArgs += @("-e", "QUILLTAP_TIMEZONE=$Timezone")
    }
}

# Add extra environment variables
foreach ($env_var in $ExtraEnv) {
    $DockerArgs += @("-e", $env_var)
}

# Image
$DockerArgs += "${Image}:${Tag}"

# Display configuration
$Platform = if ($IsLinux) { "linux" } elseif ($IsMacOS) { "macos" } else { "windows" }
Write-Host "Platform:  $Platform"
Write-Host "Data dir:  $DataDir"
Write-Host "Port:      $Port"
Write-Host "Container: $Name"
Write-Host "Image:     ${Image}:${Tag}"
if ($TzExplicit) {
    Write-Host "Timezone:  (set explicitly via -ExtraEnv)"
} elseif ($Timezone) {
    Write-Host "Timezone:  $Timezone (detected)"
} else {
    Write-Host "Timezone:  UTC (could not detect host timezone)"
}
if ($RedirectPorts) {
    Write-Host "Forwarding: $RedirectPorts"
}
Write-Host ""

if ($DryRun) {
    Write-Host "Dry run - would execute:"
    Write-Host "  docker $($DockerArgs -join ' ')"
    return
}

# Check if container already exists
$existing = docker ps -a --format '{{.Names}}' 2>$null | Where-Object { $_ -eq $Name }
if ($existing) {
    $running = docker ps --format '{{.Names}}' 2>$null | Where-Object { $_ -eq $Name }
    if ($running) {
        Write-Host "Container '$Name' is already running."
        Write-Host "Use 'docker stop $Name; docker rm $Name' to recreate."
    } else {
        Write-Host "Container '$Name' exists but is stopped. Starting it..."
        docker start $Name
    }
    return
}

Write-Host "Starting Quilltap..."
& docker @DockerArgs

Write-Host ""
Write-Host "Quilltap is running at http://localhost:${Port}"
Terminal
curl -fsSL https://raw.githubusercontent.com/foundry-9/quilltap-server/refs/heads/main/scripts/start-quilltap.sh | bash
View the full script
#!/usr/bin/env bash
set -euo pipefail

# Quilltap Docker startup script
# Detects platform, sets sensible defaults, and starts the container.
#
# Usage:
#   ./scripts/start-quilltap.sh [options]
#
# Options:
#   -d, --data-dir DIR          Data directory on host (default: platform-specific)
#   -p, --port PORT             Host port (default: 3000)
#   -n, --name NAME             Container name (default: quilltap)
#   -t, --tag TAG               Image tag (default: latest)
#   -e, --env KEY=VALUE         Extra environment variable (repeatable)
#   --restart POLICY            Restart policy (default: unless-stopped)
#   --dry-run                   Print the docker command without running it
#   -h, --help                  Show this help message
#
# Environment variables (override defaults):
#   QUILLTAP_DATA_DIR           Data directory
#   QUILLTAP_PORT               Host port
#   QUILLTAP_CONTAINER_NAME     Container name
#   QUILLTAP_IMAGE_TAG          Image tag
#   QUILLTAP_TIMEZONE           IANA timezone (default: detected from this host)
#   TZ                          Same; QUILLTAP_TIMEZONE wins if both are set
#
# The host's timezone is detected and passed to the container automatically, so
# scheduled rooms, daily budget rollover, and "today"/"yesterday" recall follow
# your clock rather than UTC. Override with `-e QUILLTAP_TIMEZONE=Europe/Paris`,
# or pin the container to UTC with `-e QUILLTAP_TIMEZONE=UTC`.

IMAGE="foundry9/quilltap"

# Resolve this host's IANA timezone name (e.g. "America/Chicago"), or print
# nothing if it can't be determined confidently.
#
# Deliberately never uses `date +%Z`: that yields an abbreviation such as "CDT",
# which Node's ICU cannot resolve back to a zone — it would silently fall back
# to UTC, which is the exact bug this is meant to avoid.
detect_timezone() {
  _tz=""

  if [ -n "${QUILLTAP_TIMEZONE:-}" ]; then
    _tz="$QUILLTAP_TIMEZONE"
  elif [ -n "${TZ:-}" ]; then
    _tz="$TZ"

  # Node's own ICU lookup — authoritative here, because it returns exactly the
  # name the container will resolve later. Tolerate node not being on PATH.
  elif command -v node >/dev/null 2>&1 &&
       _tz=$(node -p 'Intl.DateTimeFormat().resolvedOptions().timeZone' 2>/dev/null) &&
       [ -n "$_tz" ]; then
    :

  elif [ -r /etc/timezone ]; then                       # Debian/Ubuntu
    _tz=$(head -n1 /etc/timezone 2>/dev/null)
  elif command -v timedatectl >/dev/null 2>&1; then     # systemd
    _tz=$(timedatectl show --property=Timezone --value 2>/dev/null)

  # Strip through "zoneinfo/" so this covers Linux (/usr/share/zoneinfo/...)
  # and macOS (/var/db/timezone/zoneinfo/...) alike.
  elif [ -L /etc/localtime ]; then
    _tz=$(readlink /etc/localtime 2>/dev/null | sed -e 's|.*/zoneinfo/||')
  fi

  # Only emit something that looks like an IANA name. Filters out abbreviations
  # ("CDT") and stray values ("localtime", "posixrules") from odd symlinks.
  case "$_tz" in
    UTC|Etc/*|*/*) printf '%s\n' "$_tz" ;;
    *) ;;
  esac
}

# Detect platform and set default data directory
detect_defaults() {
  case "$(uname -s)" in
    Darwin)
      PLATFORM="macos"
      DEFAULT_DATA_DIR="$HOME/Library/Application Support/Quilltap"
      ;;
    Linux)
      PLATFORM="linux"
      DEFAULT_DATA_DIR="$HOME/.quilltap"
      ;;
    MINGW*|MSYS*|CYGWIN*)
      PLATFORM="windows"
      DEFAULT_DATA_DIR="${APPDATA:-$HOME/AppData/Roaming}/Quilltap"
      ;;
    *)
      PLATFORM="linux"
      DEFAULT_DATA_DIR="$HOME/.quilltap"
      ;;
  esac
}

detect_defaults

# Defaults (env vars override platform defaults)
DATA_DIR="${QUILLTAP_DATA_DIR:-$DEFAULT_DATA_DIR}"
PORT="${QUILLTAP_PORT:-3000}"
CONTAINER_NAME="${QUILLTAP_CONTAINER_NAME:-quilltap}"
IMAGE_TAG="${QUILLTAP_IMAGE_TAG:-latest}"
RESTART_POLICY="unless-stopped"
DRY_RUN=false
EXTRA_ENVS=()

# Parse arguments
while [[ $# -gt 0 ]]; do
  case "$1" in
    -d|--data-dir)
      DATA_DIR="$2"; shift 2 ;;
    -p|--port)
      PORT="$2"; shift 2 ;;
    -n|--name)
      CONTAINER_NAME="$2"; shift 2 ;;
    -t|--tag)
      IMAGE_TAG="$2"; shift 2 ;;
    -e|--env)
      EXTRA_ENVS+=("$2"); shift 2 ;;
    --restart)
      RESTART_POLICY="$2"; shift 2 ;;
    --dry-run)
      DRY_RUN=true; shift ;;
    -h|--help)
      # Print the usage block above: comment lines from line 4 until the first
      # non-comment line. awk rather than sed — BSD sed rejects `p }` without a
      # separator, so the old one-liner errored out on macOS instead of helping.
      awk 'NR<4 {next} /^#/ {sub(/^# ?/,""); print; next} {exit}' "$0"
      exit 0 ;;
    *)
      echo "Unknown option: $1" >&2
      echo "Run with --help for usage." >&2
      exit 1 ;;
  esac
done

# Create data directory if it doesn't exist
if [ "$DRY_RUN" = false ]; then
  mkdir -p "$DATA_DIR"
fi

# Build docker run command
CMD=(docker run -d
  --name "$CONTAINER_NAME"
  --restart "$RESTART_POLICY"
  -p "${PORT}:3000"
  -v "$DATA_DIR:/app/quilltap"
)

# Pass the host-side data directory so the app can display it in the UI
CMD+=(-e "QUILLTAP_HOST_DATA_DIR=$DATA_DIR")

# Linux needs explicit host.docker.internal mapping for localhost URL rewriting
if [ "$PLATFORM" = "linux" ]; then
  CMD+=(--add-host=host.docker.internal:host-gateway)
fi

# Pass the host timezone through, unless the caller already supplied one via
# -e. An explicit --env always wins; detection only fills the gap.
TIMEZONE=""
TZ_EXPLICIT=false
if [ ${#EXTRA_ENVS[@]} -gt 0 ]; then
  for env in "${EXTRA_ENVS[@]}"; do
    case "$env" in
      QUILLTAP_TIMEZONE=*|TZ=*) TZ_EXPLICIT=true ;;
    esac
  done
fi

if [ "$TZ_EXPLICIT" = false ]; then
  TIMEZONE="$(detect_timezone)"
  if [ -n "$TIMEZONE" ]; then
    CMD+=(-e "QUILLTAP_TIMEZONE=$TIMEZONE")
  fi
fi

# Add extra environment variables
if [ ${#EXTRA_ENVS[@]} -gt 0 ]; then
  for env in "${EXTRA_ENVS[@]}"; do
    CMD+=(-e "$env")
  done
fi

# Image
CMD+=("${IMAGE}:${IMAGE_TAG}")

# Run or print
echo "Platform:  $PLATFORM"
echo "Data dir:  $DATA_DIR"
echo "Port:      $PORT"
echo "Container: $CONTAINER_NAME"
echo "Image:     ${IMAGE}:${IMAGE_TAG}"
if [ "$TZ_EXPLICIT" = true ]; then
  echo "Timezone:  (set explicitly via --env)"
elif [ -n "$TIMEZONE" ]; then
  echo "Timezone:  $TIMEZONE (detected)"
else
  echo "Timezone:  UTC (could not detect host timezone)"
fi
echo ""

if [ "$DRY_RUN" = true ]; then
  echo "Dry run — would execute:"
  echo "  ${CMD[*]}"
else
  # Check if container already exists
  if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then
    echo "Container '$CONTAINER_NAME' already exists."
    if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then
      echo "It's already running. Use 'docker stop $CONTAINER_NAME && docker rm $CONTAINER_NAME' to recreate."
    else
      echo "Starting existing container..."
      docker start "$CONTAINER_NAME"
    fi
    exit 0
  fi

  echo "Starting Quilltap..."
  "${CMD[@]}"
  echo ""
  echo "Quilltap is running at http://localhost:${PORT}"
fi
Terminal
curl -fsSL https://raw.githubusercontent.com/foundry-9/quilltap-server/refs/heads/main/scripts/start-quilltap.sh | bash
View the full script
#!/usr/bin/env bash
set -euo pipefail

# Quilltap Docker startup script
# Detects platform, sets sensible defaults, and starts the container.
#
# Usage:
#   ./scripts/start-quilltap.sh [options]
#
# Options:
#   -d, --data-dir DIR          Data directory on host (default: platform-specific)
#   -p, --port PORT             Host port (default: 3000)
#   -n, --name NAME             Container name (default: quilltap)
#   -t, --tag TAG               Image tag (default: latest)
#   -e, --env KEY=VALUE         Extra environment variable (repeatable)
#   --restart POLICY            Restart policy (default: unless-stopped)
#   --dry-run                   Print the docker command without running it
#   -h, --help                  Show this help message
#
# Environment variables (override defaults):
#   QUILLTAP_DATA_DIR           Data directory
#   QUILLTAP_PORT               Host port
#   QUILLTAP_CONTAINER_NAME     Container name
#   QUILLTAP_IMAGE_TAG          Image tag
#   QUILLTAP_TIMEZONE           IANA timezone (default: detected from this host)
#   TZ                          Same; QUILLTAP_TIMEZONE wins if both are set
#
# The host's timezone is detected and passed to the container automatically, so
# scheduled rooms, daily budget rollover, and "today"/"yesterday" recall follow
# your clock rather than UTC. Override with `-e QUILLTAP_TIMEZONE=Europe/Paris`,
# or pin the container to UTC with `-e QUILLTAP_TIMEZONE=UTC`.

IMAGE="foundry9/quilltap"

# Resolve this host's IANA timezone name (e.g. "America/Chicago"), or print
# nothing if it can't be determined confidently.
#
# Deliberately never uses `date +%Z`: that yields an abbreviation such as "CDT",
# which Node's ICU cannot resolve back to a zone — it would silently fall back
# to UTC, which is the exact bug this is meant to avoid.
detect_timezone() {
  _tz=""

  if [ -n "${QUILLTAP_TIMEZONE:-}" ]; then
    _tz="$QUILLTAP_TIMEZONE"
  elif [ -n "${TZ:-}" ]; then
    _tz="$TZ"

  # Node's own ICU lookup — authoritative here, because it returns exactly the
  # name the container will resolve later. Tolerate node not being on PATH.
  elif command -v node >/dev/null 2>&1 &&
       _tz=$(node -p 'Intl.DateTimeFormat().resolvedOptions().timeZone' 2>/dev/null) &&
       [ -n "$_tz" ]; then
    :

  elif [ -r /etc/timezone ]; then                       # Debian/Ubuntu
    _tz=$(head -n1 /etc/timezone 2>/dev/null)
  elif command -v timedatectl >/dev/null 2>&1; then     # systemd
    _tz=$(timedatectl show --property=Timezone --value 2>/dev/null)

  # Strip through "zoneinfo/" so this covers Linux (/usr/share/zoneinfo/...)
  # and macOS (/var/db/timezone/zoneinfo/...) alike.
  elif [ -L /etc/localtime ]; then
    _tz=$(readlink /etc/localtime 2>/dev/null | sed -e 's|.*/zoneinfo/||')
  fi

  # Only emit something that looks like an IANA name. Filters out abbreviations
  # ("CDT") and stray values ("localtime", "posixrules") from odd symlinks.
  case "$_tz" in
    UTC|Etc/*|*/*) printf '%s\n' "$_tz" ;;
    *) ;;
  esac
}

# Detect platform and set default data directory
detect_defaults() {
  case "$(uname -s)" in
    Darwin)
      PLATFORM="macos"
      DEFAULT_DATA_DIR="$HOME/Library/Application Support/Quilltap"
      ;;
    Linux)
      PLATFORM="linux"
      DEFAULT_DATA_DIR="$HOME/.quilltap"
      ;;
    MINGW*|MSYS*|CYGWIN*)
      PLATFORM="windows"
      DEFAULT_DATA_DIR="${APPDATA:-$HOME/AppData/Roaming}/Quilltap"
      ;;
    *)
      PLATFORM="linux"
      DEFAULT_DATA_DIR="$HOME/.quilltap"
      ;;
  esac
}

detect_defaults

# Defaults (env vars override platform defaults)
DATA_DIR="${QUILLTAP_DATA_DIR:-$DEFAULT_DATA_DIR}"
PORT="${QUILLTAP_PORT:-3000}"
CONTAINER_NAME="${QUILLTAP_CONTAINER_NAME:-quilltap}"
IMAGE_TAG="${QUILLTAP_IMAGE_TAG:-latest}"
RESTART_POLICY="unless-stopped"
DRY_RUN=false
EXTRA_ENVS=()

# Parse arguments
while [[ $# -gt 0 ]]; do
  case "$1" in
    -d|--data-dir)
      DATA_DIR="$2"; shift 2 ;;
    -p|--port)
      PORT="$2"; shift 2 ;;
    -n|--name)
      CONTAINER_NAME="$2"; shift 2 ;;
    -t|--tag)
      IMAGE_TAG="$2"; shift 2 ;;
    -e|--env)
      EXTRA_ENVS+=("$2"); shift 2 ;;
    --restart)
      RESTART_POLICY="$2"; shift 2 ;;
    --dry-run)
      DRY_RUN=true; shift ;;
    -h|--help)
      # Print the usage block above: comment lines from line 4 until the first
      # non-comment line. awk rather than sed — BSD sed rejects `p }` without a
      # separator, so the old one-liner errored out on macOS instead of helping.
      awk 'NR<4 {next} /^#/ {sub(/^# ?/,""); print; next} {exit}' "$0"
      exit 0 ;;
    *)
      echo "Unknown option: $1" >&2
      echo "Run with --help for usage." >&2
      exit 1 ;;
  esac
done

# Create data directory if it doesn't exist
if [ "$DRY_RUN" = false ]; then
  mkdir -p "$DATA_DIR"
fi

# Build docker run command
CMD=(docker run -d
  --name "$CONTAINER_NAME"
  --restart "$RESTART_POLICY"
  -p "${PORT}:3000"
  -v "$DATA_DIR:/app/quilltap"
)

# Pass the host-side data directory so the app can display it in the UI
CMD+=(-e "QUILLTAP_HOST_DATA_DIR=$DATA_DIR")

# Linux needs explicit host.docker.internal mapping for localhost URL rewriting
if [ "$PLATFORM" = "linux" ]; then
  CMD+=(--add-host=host.docker.internal:host-gateway)
fi

# Pass the host timezone through, unless the caller already supplied one via
# -e. An explicit --env always wins; detection only fills the gap.
TIMEZONE=""
TZ_EXPLICIT=false
if [ ${#EXTRA_ENVS[@]} -gt 0 ]; then
  for env in "${EXTRA_ENVS[@]}"; do
    case "$env" in
      QUILLTAP_TIMEZONE=*|TZ=*) TZ_EXPLICIT=true ;;
    esac
  done
fi

if [ "$TZ_EXPLICIT" = false ]; then
  TIMEZONE="$(detect_timezone)"
  if [ -n "$TIMEZONE" ]; then
    CMD+=(-e "QUILLTAP_TIMEZONE=$TIMEZONE")
  fi
fi

# Add extra environment variables
if [ ${#EXTRA_ENVS[@]} -gt 0 ]; then
  for env in "${EXTRA_ENVS[@]}"; do
    CMD+=(-e "$env")
  done
fi

# Image
CMD+=("${IMAGE}:${IMAGE_TAG}")

# Run or print
echo "Platform:  $PLATFORM"
echo "Data dir:  $DATA_DIR"
echo "Port:      $PORT"
echo "Container: $CONTAINER_NAME"
echo "Image:     ${IMAGE}:${IMAGE_TAG}"
if [ "$TZ_EXPLICIT" = true ]; then
  echo "Timezone:  (set explicitly via --env)"
elif [ -n "$TIMEZONE" ]; then
  echo "Timezone:  $TIMEZONE (detected)"
else
  echo "Timezone:  UTC (could not detect host timezone)"
fi
echo ""

if [ "$DRY_RUN" = true ]; then
  echo "Dry run — would execute:"
  echo "  ${CMD[*]}"
else
  # Check if container already exists
  if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then
    echo "Container '$CONTAINER_NAME' already exists."
    if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then
      echo "It's already running. Use 'docker stop $CONTAINER_NAME && docker rm $CONTAINER_NAME' to recreate."
    else
      echo "Starting existing container..."
      docker start "$CONTAINER_NAME"
    fi
    exit 0
  fi

  echo "Starting Quilltap..."
  "${CMD[@]}"
  echo ""
  echo "Quilltap is running at http://localhost:${PORT}"
fi

The Shortcut (Node.js)

If you have Node.js installed and want to skip installers entirely, one command does the trick. The CLI downloads the application files on first run (~150–250 MB) and caches them locally. Subsequent launches start instantly.

Note: If you don't already have Node.js, you likely don't need this path. The desktop app now bundles its own Node.js runtime, so The Civilized Way above is faster, easier, and requires no prerequisites at all.

Terminal
npx quilltap

Or install it globally:

Terminal
npm install -g quilltap
quilltap

Supports --port and --data-dir flags, plus --update to force a fresh download. Requires Node.js 22 or later.

A word of caution: The npx path runs with your user permissions and provides no sandbox. Excellent for kicking the tires. Less excellent for leaving the AI unsupervised with your filesystem. If you start here and decide to stay, consider graduating to one of the sandboxed options.

First-Run Setup

Whichever path you chose, Quilltap walks you through a short setup wizard on first launch. It generates your encryption key, which protects your entire database at rest with authenticated ChaCha20-Poly1305 — the same cipher TLS 1.3 and WireGuard use. You can optionally add a passphrase for extra security — if you do, you'll enter it each time Quilltap starts.

Tip: If you add a passphrase, don't lose it. The encryption key it protects is the one value you can't regenerate. Safest to keep both of them, someplace secure but accessible, like a password manager.

The wizard then walks you through provider configuration: provider selection, API key validation, model selection, optional embedding and image setup, and a final test-and-confirm step. One flow creates everything you need in a single pass.

Once setup is complete, you're in.

What's in Your Data Folder

Everything Quilltap needs lives in a single directory. The application tells you exactly where at the bottom of every page.

Platform Default Location
macOS (Electron) ~/Library/Application Support/Quilltap
Windows (Electron) %APPDATA%\Quilltap
Linux ~/.quilltap
Docker Wherever you mount /app/quilltap

Inside that directory:

Path Contents
data/quilltap.db Your encrypted SQLite database (characters, chats, settings)
files/ Uploaded images and attachments
logs/ Application logs
plugins/ Installed plugins

Your data stays on your machine. Quilltap never phones home.

Updating

Desktop App

Download the latest release from the Releases page and install it over the existing version. Your data directory is untouched — nothing is lost.

Docker

docker stop quilltap
docker rm quilltap
docker pull foundry9/quilltap:latest

Then re-run your docker run command or the startup script. Your data folder is untouched — nothing is lost.

npx / npm

npx quilltap --update

Or if installed globally: npm update -g quilltap

Quick Reference (Docker)

Task Command
Stop Quilltap docker stop quilltap
Start it again docker start quilltap
View logs docker logs quilltap
Follow logs live docker logs -f quilltap

Up and running? Splendid. Now let's get you properly acquainted.

Next Steps

Troubleshooting

Desktop app won't start (macOS)

In Direct mode, the app should start without prerequisites. If you're using VM mode, ensure Xcode Command Line Tools are installed — the app will prompt you if they're missing. Check Console.app for Lima-related errors. If all else fails, try deleting the VM; the app will recreate it on next launch.

Desktop app won't start (Windows)

In Direct mode, the app should start without prerequisites. If you're using VM mode, ensure WSL2 is installed: run wsl --install in PowerShell as Administrator. Check if the distro exists with wsl --list --verbose.

"Port 3000 is already in use"

For Docker, change the host port: replace -p 3000:3000 with -p 8080:3000, then open http://localhost:8080. The desktop app handles port conflicts automatically.

Container exits immediately

Check the logs with docker logs quilltap.

Permission errors on the data folder (Linux)

On Docker Desktop (Windows/macOS), permissions are handled automatically. On native Linux, the container runs as a non-root user that may not match your host UID. If you see permission errors, you can match the container's user to your own:

docker run ... --user "$(id -u):$(id -g)" foundry9/quilltap:latest

Connecting to Ollama or other local services

The desktop app handles this for you. So does the PowerShell startup script, which probes for Ollama on its default port (11434) and forwards it automatically. To reach host services on other ports, name them yourself:

PowerShell
irm https://raw.githubusercontent.com/foundry-9/quilltap-server/refs/heads/main/scripts/start-quilltap.ps1 -OutFile start-quilltap.ps1
.\start-quilltap.ps1 -RedirectPorts "11435"
Terminal
curl -fsSL https://raw.githubusercontent.com/foundry-9/quilltap-server/refs/heads/main/scripts/start-quilltap.sh | bash -s -- -e HOST_REDIRECT_PORTS=11435
Terminal
curl -fsSL https://raw.githubusercontent.com/foundry-9/quilltap-server/refs/heads/main/scripts/start-quilltap.sh | bash -s -- -e HOST_REDIRECT_PORTS=11435

Multiple ports are comma-separated: HOST_REDIRECT_PORTS=3030,8080. The container bridges forwarded ports internally via socat, so localhost works from inside Docker — no host.docker.internal gymnastics required. The Windows script takes -RedirectPorts directly, which is why it must be downloaded before it is run — a piped iex has nowhere to put an argument.