Files
eleith 9691ea7bce
ci/woodpecker/pr/calendar Pipeline was successful
ci/woodpecker/pr/cli Pipeline was successful
ci/woodpecker/pr/config Pipeline was successful
ci/woodpecker/pr/db Pipeline was successful
ci/woodpecker/pr/jobs Pipeline was successful
ci/woodpecker/pr/video-chat Pipeline was successful
ci/woodpecker/pr/web Pipeline was successful
ci/woodpecker/pr/worker Pipeline was successful
ci/woodpecker/push/calendar Pipeline was successful
ci/woodpecker/push/cli Pipeline was successful
ci/woodpecker/push/config Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/jobs Pipeline was successful
ci/woodpecker/push/video-chat Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/worker Pipeline was successful
perf(web): generate avatar thumbnails automatically
2026-11-03 06:29:22 -08:00

23 KiB
Raw Permalink Blame History

Configuration

One when.yaml for the web app and worker. Auth, availability, calendars, email, and what guests can book. No settings scavenger hunt.

Copy the example to config/when.yaml and edit it. It documents every field; this page explains the rules. The running app serves its editor schema at /schema.json:

# yaml-language-server: $schema=http://localhost:3000/schema.json

Use your app's address if different. In a checkout, the generated schema also lives at packages/config/src/config.schema.json; the source of truth is the TypeBox schema.

File shape

Key Required? Purpose / default
auth Yes One admin sign-in strategy.
user Yes Owner details and appearance.
smtp Yes Outgoing appointment email.
providers No Named services and their calendars; defaults to {}. Meetings must still reference declared calendars.
schedules Yes At least one named availability schedule.
meetings Yes At least one bookable meeting.
version No Config format version, not the app release. Defaults to 1; only 1 is accepted.
database No SQLite paths.
url No Public and internal service URLs.
prometheus No Metrics; off by default.

providers, schedules, and meetings are maps, not lists. Their keys are their names; a meeting's key is also its URL slug. Use lowercase letters, digits, and hyphens, starting with a letter or digit. The same rule applies to calendar keys.

There is no top-level calendars, no provider or schedule name, and no meeting name or slug field. Calendars belong inside their provider:

providers:
  cloud:
    type: caldav
    url: 'https://cloud.example.com/remote.php/dav/'
    username: jane
    password: '${WHEN_CALDAV_PASSWORD}'
    calendars:
      work:
        href: 'calendars/jane/work/'

Unknown options are rejected. References to schedules, calendars, providers, and form fields are checked at load time.

Environment variables

Any string value can contain ${ENV_VAR}. Values are substituted before runtime validation, including in defaults. This is optional: literal values in private YAML work without an environment file. If you prefer interpolation, the sample Compose file passes an optional .env to web, worker and CLI. Keep files containing secrets private and out of Git.

  • ${VAR} fails loading if the variable is unset.
  • ${VAR:-fallback} uses the fallback if the variable is unset, not if it is empty.
  • $$ produces a literal $.

For example: password: '${WHEN_SMTP_PASSWORD}'. Deployment variables such as CONFIG_PATH, AUTH_SECRET, and ORIGIN are covered in deployment.

auth

Choose exactly one strategy for /admin.

auth:
  credentials:
    username: admin
    password: '' # required: set your own password before starting

Or replace it with OIDC:

auth:
  oidc:
    issuer: 'https://auth.example.com'
    client_id: when
    client_secret: '' # required: your provider's client secret
    # name: 'Single Sign-On' # default display name

credentials.username is required; password must be non-empty. Set it directly in YAML or use interpolation. Omitting it still uses ${WHEN_ADMIN_PASSWORD} for existing environment-driven configs. OIDC requires issuer, client_id, and client_secret; name defaults to Single Sign-On.

Optional auth.secret is a session signing key of at least 32 characters after interpolation. With no YAML secret, web uses AUTH_SECRET if set; otherwise it generates a secure random key at startup. Generated keys sign everyone out on web restart. Set a stable YAML secret (or keep AUTH_SECRET) to preserve sessions. Generate one once with openssl rand -base64 32. Ordinary config reloads keep the current key. Empty or short configured keys are errors, not requests to generate one. Structural CLI validation leaves interpolation references unresolved; config validate --check-env checks the resolved YAML key's length.

Both strategies work in production. Choose based on who can reach sign-in:

  • Credentials: suitable on a LAN, VPN, or behind an authenticating proxy. Native sign-in throttling uses the trusted X-Real-IP header. Keep the backend private and have the proxy overwrite that header; this is not durable lockout.
  • OIDC: recommended for an internet-facing deployment. Throttling, lockout, password policy, and MFA belong to your identity provider.

If you expose credentials sign-in publicly, also enforce rate limiting at the reverse proxy.

user

Field Default Meaning
name Required Owner's display name.
email Required Owner's valid email address.
timezone ${TZ:-UTC} IANA zone, such as America/New_York. Schedule windows use this zone.
appearance {} Optional overrides below.

Appearance

Every field under user.appearance is optional.

Field Default
title if not now, when?
description find some time and we can meet
app_icon_path /assets/images/app-icon.svg
avatar_path /assets/images/avatar.svg
favicon_path /assets/images/favicon.svg
opengraph_path /assets/images/opengraph.png
font_name Noto Sans
font_path Unset; custom woff2 file registered as font_name.
primary_light_color #166534
primary_dark_color #34d399
background_light_color #f5f5f5
background_dark_color #0a0a0a
text_light_color #171717
text_dark_color #ededed

The default icon and favicon are bundled calendar marks. The default avatar is generated from user.name; the default share card uses the appearance settings. Bundled font families are Noto Sans, Lato, Outfit, and Inter.

All *_path values must be root-relative paths served by this app. Remote URLs are rejected. Put custom files in ./public/, alongside config/ and data/, and reference them as /public/logo.png. WHEN_PUBLIC_DIR overrides that directory.

For an avatar_path of /public/photo.webp, /avatar/public/photo.webp serves the original and /avatar/public/photo.webp?thumbnail=true serves a thumbnail. The path is only checked against the configured avatar: mismatches return 404, and the app always loads the source selected by configuration, never by the request path. Changing the configured path changes the browser's cache key without embedding a path in a query parameter.

Web pages request thumbnails automatically, resizing raster avatars to at most 216 × 216 pixels (enough for the largest 72-pixel avatar at 3× density). thumbnail=false also serves the full image; other values are rejected. Keep setting only avatar_path; no separate thumbnail or image tool is needed. Generated share cards continue to load the original image.

Thumbnail responses preserve SVGs, small images and animations. It uses WebP only when the result is smaller, and falls back to the original on conversion failure. Sources above 8 MiB are served directly instead of processed. The app caches the current derivative in memory and revalidates its source when the route is requested. Concurrent requests share source loading and resizing. Browser caching lasts up to one hour; changing avatar_path also changes the web thumbnail's cache key. The source file is never overwritten.

Known limitation: Takumi currently ignores EXIF orientation metadata. A photo that relies on that metadata may appear rotated in the generated thumbnail. This also occurs with Takumi 2.19.0; it is not corrected by this optimization.

Colors accept three- or six-digit hex values. Each primary color generates a muted tonal scale for its mode; background and text colors override the surfaces and text.

smtp

Required. The worker uses this server for appointment emails.

Field Default Meaning
host Required SMTP hostname.
port 587 Integer from 1–65535.
username Required SMTP username.
password Required SMTP password; literal or optional interpolation.
from noreply@<url.app domain> Sender address your server is allowed to use.

from is also the organizer on guest-facing calendar invites, keeping the host's own address out of those fields. The sender display name is always user.name.

providers

Named services, each containing a calendars map. See the example for complete provider blocks.

type Required fields Optional fields
google client_id, client_secret refresh_token (default ''), calendars (default {}).
caldav url, username, password calendars (default {}).
nextcloud url, username, password calendars (default {}). Also supports Talk video links.

For CalDAV, url is the DAV endpoint. For Nextcloud, it is the instance base URL.

Calendars

Calendar keys must be unique across all providers: meetings reference them by name alone. They supply busy times, receive appointments, or both.

Provider Calendar field Meaning
Google id (required) Google calendar ID, such as primary.
CalDAV / Nextcloud href (required) Path joined to the provider's url, or a full HTTP(S) URL.
All sync.refresh_every_minutes Busy-time refresh interval; integer ≥ 1, default 10.

A calendar needs no type or provider field; its parent supplies both.

Google connection

An empty or omitted refresh_token means not connected. The config can load, but the provider cannot reach Google until you connect it.

Connect from /admin. Google issues the token through browser consent; the admin UI shows it once. Save it as the provider's YAML refresh_token, or use optional environment interpolation. Environment changes require recreating the containers.

schedules

Each named schedule has a weekly list. Omit weekly to use Monday–Friday, 09:00–17:00. For example, standard: {} is a complete schedule.

Each rule needs days, from, and to:

schedules:
  standard:
    weekly:
      - days: [mon, tue, wed, thu, fri]
        from: '09:00'
        to: '12:00'
      - days: [mon, tue, wed, thu, fri]
        from: '13:00'
        to: '17:00'
  • Days are mon, tue, wed, thu, fri, sat, sun.
  • Times are 24-hour HH:MM in user.timezone; from must be earlier than to.
  • Availability is the union of all rules. Repeat days for split windows.
  • Days absent from every rule are unavailable. An explicit weekly list cannot be empty.

meetings

The key is the booking URL slug: chat gives /schedule/chat.

meetings:
  chat:
    title: 'A quick chat'
    schedule: standard
    booking_calendar: work
    duration_minutes: 30
    additional_duration_minutes: [15, 60]

Booking options

Field Default Meaning
title Required Text guests see.
schedule Required Declared schedule name.
booking_calendar Required Declared calendar for confirmed bookings; always checked for conflicts.
duration_minutes 30 Default guest-selected length; positive integer.
additional_duration_minutes [] Other positive integer lengths guests may choose.
description Unset Brief description.
visibility public unlisted hides it from the homepage, not from anyone with its link.
require_approval true Host approves bookings; false confirms instantly.
additional_busy_calendars [] Other calendar names to check for conflicts.
show_slots false true draws slot buttons; false uses a bare drag timeline snapping to the same slots.
location Unset Static address, phone number, or link.
note Unset Host note shown to guests after booking.
video_chat Unset Dynamic video link settings below.
form_fields Unset Custom booking form below.
start_times_every_minutes 30 Positive integer slot step, independent of offered durations.
notice_minutes 120 Minimum lead time; integer ≥ 0.
padding_before_minutes 0 Padding before appointments; integer ≥ 0.
padding_after_minutes 0 Padding after appointments; integer ≥ 0.
daily_booking_limit null Positive integer daily cap; null means unlimited.

Duration choices are deduplicated and sorted numerically. New bookings start with duration_minutes, rescheduling starts with the booked length, and a valid duration link overrides either. The first choice is not necessarily the default. Hosts also get preset lengths; guests do not gain those choices except to retain a booked length. The slot step does not change with the selected duration. A step shorter than a duration produces overlapping candidate slots, visible when show_slots is enabled.

Custom forms

Without form_fields, the form asks for a required guest name, required guest email, and optional notes. Setting form_fields replaces that default form.

Supply 1–10 fields, with unique name values. Each needs name, type, and label; required defaults to false, and show_when is optional.

Type Rules
guest_name Exactly one, with required: true.
guest_email At most one; optional in a custom form.
event_location At most one. With choices, guests pick a location; without, they type it.
choice Requires a non-empty choices list of non-empty strings, in display order.
text, number, phone, paragraph General questions; choices is not accepted.

This is a form_fields block inside a meeting:

form_fields:
  - name: name
    type: guest_name
    label: 'Your name'
    required: true
  - name: contact
    type: choice
    label: 'How should we reach you?'
    choices: [email, phone]
    required: true
  - name: email
    type: guest_email
    label: 'Your email'
    required: true
    show_when:
      - field: contact
        equals: email
  - name: phone
    type: phone
    label: 'Your phone number'
    required: true
    show_when:
      - field: contact
        equals: phone

Every show_when condition must hold (AND). Conditions reference earlier fields:

  • equals: phone matches one value.
  • equals: [phone, email] accepts any listed value.
  • Omitting equals tests whether the field is filled.

For a choice controller, matched values must exist in its choices. Hidden fields are never required, and their answers are not recorded.

video_chat.provider names a declared google or nextcloud provider, not a calendar. Google Meet also requires a Google booking calendar. Generic CalDAV providers do not generate video links.

Choose one attachment rule under video_chat.attach:

Rule Behavior
Omitted, or auto: true Generate a link automatically on booking.
auto: false Host generates the link on demand.
when: [...] Generate automatically when every form-answer condition holds.

Use auto or when, not both. when is a non-empty list using the same field / equals conditions as forms, referencing fields in this meeting. For example, inside a meeting with a contact field:

video_chat:
  provider: nextcloud-service
  attach:
    when:
      - field: contact
        equals: video

A choice field must include the matched value (video here). Links are generated per appointment as Google Meet URLs or Nextcloud Talk rooms.

database

Field Default Contents
app ./data/when.sqlite Appointments, mirrored busy times, service status.
queue ./data/openworkflow.sqlite Background job queue.

Relative paths resolve against the config directory's parent, not the shell's working directory. With config/when.yaml, config/ and data/ are siblings. Web and worker must load the same config and share these files.

WHEN_DATABASE_PATH and WHEN_QUEUE_DB_PATH override the respective YAML paths. Treat both databases and their backups as sensitive; see persistence.

url

Field Default Used for
app ${ORIGIN:-http://localhost:5173} Public base URL, including scheme; links in emails and calendar events.
internal ${WHEN_URL_INTERNAL:-} Worker → web, including branding images embedded in email. Empty falls back to app.
worker http://when-worker:9000 Web → worker telemetry and metrics.

Set url.app in YAML to your public HTTPS URL for authentication and generated links. ORIGIN remains an optional default when that YAML field is omitted; an explicit YAML value takes precedence. SvelteKit 3's native adapter ignores ORIGIN and derives its request origin from trusted proxy headers and Host instead. These must match the configured public URL; see the proxy requirements.

prometheus

Field Default Meaning
enabled false Expose /metrics; instrumentation remains active.
token ${WHEN_METRICS_TOKEN:-} Bearer token required for scraping.

When disabled, /metrics returns 404. When enabled without a non-empty token, it returns 500, not public metrics. Scrape with Authorization: Bearer <token>. See operating endpoints and the metrics reference for collection health, action counts and job semantics.