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.
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.
Launch
On first run, Quilltap will:
- Present a splash screen where you choose your data directory and runtime mode
- Start the backend (instant in Direct mode; a short wait for VM or Docker)
- 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:
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.
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}"
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
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.
npx quilltap
Or install it globally:
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 StepsTroubleshooting
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:
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"
curl -fsSL https://raw.githubusercontent.com/foundry-9/quilltap-server/refs/heads/main/scripts/start-quilltap.sh | bash -s -- -e HOST_REDIRECT_PORTS=11435
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.