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. Docker is the boundary Quilltap itself provides: a container is a real wall, if not an especially thick one, and an AI-generated script that misbehaves inside it misbehaves with no access to your host beyond the folders you bound in yourself. A virtual machine is the thicker wall — a genuine locked room — but Quilltap no longer builds or keeps one for you; if you want that, you raise the machine yourself and run Quilltap inside it, in which case QUILLTAP_HOST_IP is the supported way to let it reach services back on the host. 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 Long-Distance Call Desktop App — Remote The Dockworker's Route Docker The Shortcut Node.js / npx
You must install Nothing extra — Electron bundles Node.js Nothing on this machine — but a Quilltap must already be running somewhere you can reach Docker Desktop (Win/Mac) or Docker Engine (Linux) Node.js 24+
First launch Fastest — uses Electron's embedded Node.js, runs directly Immediate — nothing to start; you supply an address Fast — pulls the container image, starts in seconds Fast — downloads app files, runs directly
Subsequent launches Near-instant Near-instant, assuming the far end is awake Near-instant Near-instant
AI sandbox ✗ No isolation Runs with your permissions ⚠ Whatever the far end has Inherited from the machine you point at ⚠ Container isolation The strongest boundary Quilltap ships ✗ 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 Whatever the far end keeps Yes — mount different volumes Yes — --data-dir flag
Best for Most users — fastest start, zero prerequisites Anyone whose Quilltap already lives elsewhere — a household server, a container, a dev machine Safety-conscious users, 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 boundary around that behavior, switch to Docker mode from the splash screen. The Electron app lets you toggle between Direct, Docker, and Remote back ends at any time — no commitment required. And should you want a wall thicker than a container, build the virtual machine yourself, run Quilltap inside it, and let the app reach it as a Remote — it will speak to any Quilltap address you can reach.

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, the Dockworker's Route is the most civilized sandbox on offer — and if you use the Electron app to start it, it even lets you switch between the back ends from its splash screen, so you needn't commit to any one on day one. Remote, the third of them, asks nothing whatever of this machine: give it the address of a Quilltap running elsewhere — a container on the household server, a dev server, an npx quilltap instance humming away in another window — and the desktop app becomes a native window onto that.

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, it can also run its backend inside a Docker container; and where the backend is already running somewhere else, it can simply be pointed at that instead. All three are 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. For a container around the backend, install Docker Desktop and choose Docker on the splash screen; and if a Quilltap is already running elsewhere, Remote wants nothing from you but its address.

Windows

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

In Direct mode (the default), you need nothing else — just launch and go. For a container around the backend, install Docker Desktop and choose Docker on the splash screen; and if a Quilltap is already running elsewhere — on the household server, say, or in a virtual machine of your own making — Remote wants nothing from you but its address. The app checks whatever you have chosen 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 — or it can be pointed at a Quilltap already running elsewhere, which is what Remote is for.

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 Docker; no wait at all for Remote, which is already running)
  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 back ends 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.

Docker — Requires Docker Desktop or Docker Engine. Container-level isolation — good boundary, not airtight, and the strongest one Quilltap ships.

Remote — Starts nothing at all, and instead points the window at a Quilltap already running somewhere else: a container on your server, a virtual machine you keep yourself, a dev server, an npx quilltap instance. Any Quilltap address you can reach will do; the isolation, such as it is, is whatever that machine already had.

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.

Reverse proxy tip: Since 4.9 the house no longer makes your open tabs ask every few seconds whether anything has happened — it tells them, over a WebSocket. A proxy standing in front of Quilltap must therefore forward the Upgrade and Connection headers, and must present the instance under the same origin the browser is using: the socket accepts nothing else, browsers declining to apply CORS to upgrade requests, which leaves that check the only doorman on duty. Where the socket cannot be established, nothing breaks — every surface falls quietly back to the polling it did in 4.8, and the tasks queue’s switch, honestly labelled “Fallback polling (5s),” is what you are then watching. You lose the immediacy, not the function.

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 24 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 key file on disk is only a wrapper; what it wraps is the master secret that actually decrypts your database, and that is the one value nothing on earth can regenerate. Lose it and you own a very well-encrypted brick. A lost or locked key file, mercifully, now has a way back: with the server shut down, npx quilltap instances restore-key <name> rebuilds it from the master secret, taken from ENCRYPTION_MASTER_PEPPER or a hidden prompt and never from a flag, where it would linger in your shell history for anyone to find. It opens every encrypted database in the directory with the secret you supply before it writes a byte, and refuses outright if one of them declines — a key file wrapping the wrong secret is worse than no key file at all, since the server will cheerfully unwrap it, hand the cipher a key that decrypts nothing, and report a perfectly intact database as corrupt. Safest, still, to keep both passphrase and secret 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

In Direct mode the app carries its own Node.js runtime and should start with no prerequisites whatever, on any platform. In Docker mode, check that Docker Desktop or Docker Engine is actually running: Quilltap can start a container, but it cannot start your container engine for you. In Remote mode, put the address into an ordinary browser first — a Quilltap that will not answer there will not answer in the app either, and you have then narrowed the trouble to the far end.

"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.