Post

Syncing Nextcloud and Outlook Calendars into Open WebUI with caldav2markdown and OIKB

Syncing Nextcloud and Outlook Calendars into Open WebUI with caldav2markdown and OIKB

calendar

Giving Open WebUI access to calendar data

Open WebUI is a fantastic product, but one issue I had when trying to incorporate it into my life is the lack of any calendar integrations. I could use Outlook or another connected service directly for some tasks, but that is not the workflow I wanted. I wanted calendar data to exist as searchable Knowledge Base content inside Open WebUI so it could be referenced naturally in chats.

This article documents the final working setup used to pull calendar data from Nextcloud and Outlook, convert that data into Markdown with caldav2markdown, and sync the resulting files into an Open WebUI Knowledge Base with oikb.

The final setup uses:

  • caldav2markdown as the calendar-to-Markdown conversion tool
  • Public ICS feeds for Nextcloud and Outlook calendars
  • Three dedicated YAML config files, one per calendar source
  • A host bind mount at /opt/appdata/caldav2markdown
  • A shared output tree at /opt/appdata/caldav2markdown/output
  • oikb to mirror generated Markdown files into Open WebUI
  • A dedicated Open WebUI Knowledge Base for calendar data

The resulting pipeline looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Nextcloud ICS / Outlook ICS
        |
        v
caldav2markdown container
        |
        | daily markdown files
        v
/opt/appdata/caldav2markdown/output
        |
        | read-only bind mount into oikb
        v
/sources/caldav2markdown
        |
        | incremental Knowledge Base sync
        v
Open WebUI Knowledge Base

Environment Summary

The host path used for this project is:

1
/opt/appdata/caldav2markdown

That host path is bind-mounted into the running container as:

1
/data

The compiled binary lives at:

1
/opt/appdata/caldav2markdown/bin/caldav2markdown

The generated Markdown output path is:

1
/opt/appdata/caldav2markdown/output

The OIKB config directory already in use on the host is:

1
/opt/appdata/oikb

The Open WebUI Knowledge Base UUID used for calendar sync is:

1
25447265-97a9-4849-8b70-f748d320f4c3

Final Working Calendar Sources

The final working setup uses three ICS feeds:

  1. A public Nextcloud ICS calendar
  2. A published Outlook work ICS calendar
  3. A published Outlook school ICS calendar

Representative source formats are:

1
2
3
https://nextcloud.cjs-cloud.com/remote.php/dav/public-calendars/Your_Feed_Here?export
https://outlook.office365.com/owa/calendar/<work-mailbox>/<feed-id>/calendar.ics
https://outlook.office365.com/owa/calendar/<school-mailbox>/<feed-id>/calendar.ics

The current observed sync results are:

  • Nextcloud ICS feed: 9 events, 0 tasks, 7 daily files created
  • Outlook Work ICS feed: 170 events, 0 tasks, 30 duplicates skipped, 140 daily files created
  • Outlook School ICS feed: 0 events, 0 tasks, no failures

The school feed is still configured correctly, but it returned no events in the tested date window.


Why ICS Was Used Instead of CalDAV

The original authenticated Nextcloud CalDAV discovery path was explored first, but for this environment the simplest reliable ingestion path ended up being published ICS feeds.

ICS was the better fit here because:

  • Nextcloud public calendar exports were easy to consume
  • Outlook calendar publishing exposed direct ICS URLs
  • The goal was read-only ingestion into Open WebUI
  • No bidirectional sync or write-back was needed

This pipeline is focused on durable read-only ingestion, not calendar management.


Host Directory Layout

The working host layout is:

1
2
3
4
5
6
7
8
9
10
11
12
13
/opt/appdata/caldav2markdown/
├── bin/
│   └── caldav2markdown
├── config-nextcloud.yaml
├── config-school.yaml
├── config-work.yaml
├── output/
│   ├── 0001/
│   ├── 2026/
│   └── 2027/
├── sync.sh
├── caldav2markdown.log
└── caldav2markdown.log.bak

The generated output/ tree stores daily Markdown files in year/month/day format, for example:

1
2
3
4
/opt/appdata/caldav2markdown/output/2026/06/2026-06-07.md
/opt/appdata/caldav2markdown/output/2026/07/2026-07-31.md
/opt/appdata/caldav2markdown/output/2026/09/2026-09-19.md
/opt/appdata/caldav2markdown/output/2027/06/2027-06-22.md

One malformed output file also appeared at:

1
/opt/appdata/caldav2markdown/output/0001/01/0001-01-01.md

That file is excluded from OIKB sync.


Final Config Model

The current setup does not use one giant YAML file for all calendars. It uses one YAML file per ICS source.

config-nextcloud.yaml

1
2
3
4
5
6
7
8
9
10
source_mode: ics

ics_url: "https://nextcloud.cjs-cloud.com/remote.php/dav/public-calendars/Your_Feed_Here?export"
ics_auth: none

output_dir: /data/output
use_frontmatter: true
use_hashtags: true
ignore_descriptions: true
exclude_calendars: "Day Planning"

config-work.yaml

1
2
3
4
5
6
7
8
9
source_mode: ics

ics_url: "https://outlook.office365.com/owa/calendar/<work-mailbox>/<feed-id>/calendar.ics"
ics_auth: none

output_dir: /data/output
use_frontmatter: true
use_hashtags: true
ignore_descriptions: true

config-school.yaml

1
2
3
4
5
6
7
8
9
source_mode: ics

ics_url: "https://outlook.office365.com/owa/calendar/<school-mailbox>/<feed-id>/calendar.ics"
ics_auth: none

output_dir: /data/output
use_frontmatter: true
use_hashtags: true
ignore_descriptions: true

Important note about output

Although output_dir: /data/output is present in the YAML files, the deployed binary only wrote into the intended bind-mounted output tree reliably when -output /data/output was passed explicitly on the command line. Because of that, the final working setup enforces output location in sync.sh.


Final sync.sh

The final calendar sync logic uses a host-side script that calculates a rolling date window, calls each source-specific config, and forces all output into the shared /data/output tree.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#!/bin/sh
START=$(date -d @$(($(date +%s) - 2592000)) +%Y-%m-%d)
END=$(date -d @$(($(date +%s) + 31536000)) +%Y-%m-%d)

LOG="/data/caldav2markdown.log"
OUT="/data/output"

echo "[$(date)] Starting calendar sync..." >> "$LOG"
echo "[$(date)] Date range: $START to $END" >> "$LOG"

echo "[$(date)] Syncing Nextcloud..." >> "$LOG"
/usr/local/bin/caldav2markdown \
  -config /data/config-nextcloud.yaml \
  -start "$START" \
  -end "$END" \
  -output "$OUT" >> "$LOG" 2>&1
RC1=$?

echo "[$(date)] Syncing Work (Outlook)..." >> "$LOG"
/usr/local/bin/caldav2markdown \
  -config /data/config-work.yaml \
  -start "$START" \
  -end "$END" \
  -output "$OUT" >> "$LOG" 2>&1
RC2=$?

echo "[$(date)] Syncing School (UNCG)..." >> "$LOG"
/usr/local/bin/caldav2markdown \
  -config /data/config-school.yaml \
  -start "$START" \
  -end "$END" \
  -output "$OUT" >> "$LOG" 2>&1
RC3=$?

if [ "$RC1" -eq 0 ] && [ "$RC2" -eq 0 ] && [ "$RC3" -eq 0 ]; then
  echo "[$(date)] Sync complete." >> "$LOG"
  exit 0
else
  echo "[$(date)] Sync failed. Exit codes: nextcloud=$RC1 work=$RC2 school=$RC3" >> "$LOG"
  exit 1
fi

What this script does

This script calculates a rolling window of about 30 days back and 365 days forward using epoch math. That avoids BusyBox date issues with relative-date strings inside Alpine.

It then runs caldav2markdown three times, once per calendar source, and writes all generated Markdown into the same output tree. Because the tool merges into daily files, the final result is one combined date-based note stream containing personal, work, and school events.


Final Portainer Stack

The final stable Portainer stack keeps the container lightweight and runs the sync script on a 30-minute cron schedule:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
version: "3.8"
services:
  caldav2markdown:
    image: alpine:3.21
    container_name: caldav2markdown
    restart: unless-stopped
    environment:
      - TZ=America/New_York
    volumes:
      - /opt/appdata/caldav2markdown:/data
      - /opt/appdata/caldav2markdown/bin/caldav2markdown:/usr/local/bin/caldav2markdown:ro
    entrypoint: ["sh", "-c"]
    command:
      - |
        apk add --no-cache ca-certificates tzdata > /dev/null 2>&1
        echo '*/30 * * * * /data/sync.sh' >> /etc/crontabs/root
        crond -f -l 2

This keeps the stack simple and makes the real logic live in /data/sync.sh, where it is easy to edit and survives restarts.


Verifying the Running Container

Useful validation commands:

1
2
3
4
5
docker ps | grep caldav2markdown
docker exec -it caldav2markdown /bin/sh -c "/data/sync.sh"
docker exec -it caldav2markdown /bin/sh -c "ls -R /data/output"
find /opt/appdata/caldav2markdown/output -type f | head -n 20
tail -n 50 /opt/appdata/caldav2markdown/caldav2markdown.log

These verify:

  • the container is running
  • the sync script executes successfully
  • markdown files are being written inside the container
  • the same files are visible on the host via the bind mount
  • the log shows per-source results

Sync Results Observed During Testing

The currently validated run showed:

  • Nextcloud: 9 events, 7 daily files created
  • Work Outlook: 170 events, 30 duplicates skipped, 140 daily files created
  • School Outlook: 0 events, no errors

Representative output structure after a successful sync looked like:

1
2
3
4
/data/output/2026/05/2026-05-25.md
/data/output/2026/06/2026-06-24.md
/data/output/2026/12/2026-12-29.md
/data/output/2027/06/2027-06-22.md

This confirmed that the work calendar is the primary high-volume source, while the Nextcloud feed adds a smaller set of personal events into the same merged daily note stream.


OIKB Configuration

OIKB is configured from .oikb.yaml in the OIKB config directory. The calendar source should be added under the top-level sources: key.

1
2
3
4
5
6
7
8
9
10
sources:
  - name: caldav-calendar
    source: /sources/caldav2markdown
    kb-id: 25447265-97a9-4849-8b70-f748d320f4c3
    interval: 30m
    filter:
      include:
        - "**/*.md"
      exclude:
        - "0001/**"

Why this source path works

On the Docker host, the Markdown files live at:

1
/opt/appdata/caldav2markdown/output

Inside the OIKB container, that host path is mounted read-only as:

1
/sources/caldav2markdown

The .oikb.yaml file must always reference the container-visible source path, not the host path.


Updated OIKB Docker Compose Service

The OIKB service needs the calendar output mounted into the container:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
oikb:
  image: ghcr.io/open-webui/oikb:latest
  container_name: oikb
  restart: unless-stopped
  environment:
    - TZ=America/New_York
    - OPEN_WEBUI_URL=http://openwebui:8080
    - OPEN_WEBUI_API_KEY=your-openwebui-api-key
    - OIKB_API_KEY=your-oikb-daemon-api-key
    - LOG_FORMAT=json
  volumes:
    - /opt/appdata/oikb:/config:ro
    - /opt/appdata/caldav2markdown/output:/sources/caldav2markdown:ro
  working_dir: /config
  command: daemon
  ports:
    - "8035:8080"
  depends_on:
    - openwebui

The important part is the additional read-only source mount for the calendar Markdown output.


Refresh Frequency

There are two schedules in this design:

  1. caldav2markdown refreshes the source Markdown files every 30 minutes through Alpine cron.
  2. oikb syncs the generated Markdown files into Open WebUI every 30 minutes through the per-source interval: 30m setting.

That keeps calendar data reasonably fresh without adding unnecessary complexity.


Useful Commands

Calendar Sync

Run the sync script manually:

1
docker exec caldav2markdown sh /data/sync.sh

List generated files:

1
find /opt/appdata/caldav2markdown/output -type f | sort | head -n 50

Watch the log:

1
tail -f /opt/appdata/caldav2markdown/caldav2markdown.log

Check the container-side output tree:

1
docker exec -it caldav2markdown /bin/sh -c "ls -R /data/output"

OIKB

Restart OIKB after editing .oikb.yaml:

1
docker restart oikb

Check recent OIKB logs:

1
docker logs oikb --tail 50

Validate OIKB config:

1
docker exec -it oikb oikb validate

Dry-run the calendar sync source:

1
docker exec -it oikb oikb sync --name caldav-calendar --dry-run

Run a manual calendar sync:

1
docker exec -it oikb oikb sync --name caldav-calendar

List files in the calendar Knowledge Base:

1
docker exec -it oikb oikb ls --kb-id 25447265-97a9-4849-8b70-f748d320f4c3

Troubleshooting Notes

Problem: output_dir in YAML did not populate /data/output

Observed behavior:

The YAML configs loaded correctly, but files did not appear under /data/output until -output /data/output was added to the runtime command.

Fix:

Pass the output directory explicitly in sync.sh:

1
-output /data/output

Problem: OIKB reported 0 files found

Cause:

The initial OIKB filter used *.md, which only matched top-level markdown files and missed nested year/month output paths.

Fix:

Use a recursive include pattern:

1
2
3
4
5
filter:
  include:
    - "**/*.md"
  exclude:
    - "0001/**"

Problem: 0001/01/0001-01-01.md appeared in output

Cause:

At least one generated item was written with an invalid or malformed date.

Fix:

Exclude that path from OIKB sync:

1
2
exclude:
  - "0001/**"

Problem: BusyBox date rejected GNU relative date syntax

Cause:

Alpine’s BusyBox date does not behave like GNU date for relative-date strings such as -30 days.

Fix:

Use epoch math:

1
2
START=$(date -d @$(($(date +%s) - 2592000)) +%Y-%m-%d)
END=$(date -d @$(($(date +%s) + 31536000)) +%Y-%m-%d)

Open WebUI Usage Notes

A dedicated Knowledge Base was used for the calendar pipeline. That keeps sync-managed content isolated from manually uploaded files and other unrelated knowledge sources.

Good prompt examples include:

1
Based on my calendar, give me a brief for today and what is coming up this week.
1
Summarize my work meetings and personal commitments for the next 7 days.
1
What deadlines, appointments, or scheduling conflicts should I know about?

Final Architecture

The final architecture looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Nextcloud / Outlook ICS feeds
        |
        v
caldav2markdown
        |
        | writes daily markdown notes
        v
/opt/appdata/caldav2markdown/output
        |
        | mounted read-only into oikb
        v
/sources/caldav2markdown
        |
        | incremental sync every 30 minutes
        v
Open WebUI Knowledge Base
        |
        | attached to chats/models
        v
AI calendar briefings and schedule questions

The end result is a lightweight, durable, and repeatable calendar-to-Knowledge-Base pipeline that uses ICS feeds, merges them into daily Markdown files, and keeps them queryable inside Open WebUI.

This post is licensed under CC BY 4.0 by the author.