26 KiB
JARVIS Nextcloud Calendar Integration Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give JARVIS read/write access to the Nextcloud "FFW-Onza-Alle" CalDAV calendar: a dashboard widget listing upcoming events, and chat-driven querying + confirmed event creation via Claude tool use.
Architecture: A caldav-backed helper pair (list_upcoming_events, create_event) wrapped in asyncio.to_thread (same pattern as the existing Postgres db_query helper). A new protected GET /api/v1/calendar/events endpoint serves the dashboard widget. /api/v1/chat gains two Claude tools that call the same helpers, with a system-prompt rule requiring explicit user confirmation before any write.
Tech Stack: caldav + icalendar (Python, new dependencies), FastAPI (existing require_admin_key/db_query patterns), Anthropic Messages API tool use, React (existing apiFetch pattern).
Spec: docs/superpowers/specs/2026-09-13-calendar-integration-design.md
Global Constraints
- No dedicated git repository for this project — every "Commit" step is replaced by "confirm the file is saved".
- SSH:
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98. Backend files:Claude outputs/. Frontend files:web/. - CalDAV connectivity is already verified working (13.09.2026): read found 11 real events, a test event was created and deleted successfully using the exact credentials below.
- Credentials (already provided, put in
.env— never hardcode in source):NEXTCLOUD_CALDAV_URL=https://cloud.ffw-onza.de/remote.php/dav/calendars/jonny/ffw-onza-alle/,NEXTCLOUD_USER=jonny,NEXTCLOUD_APP_PASSWORD=j9Ywc-9Pnbk-8rQQw-Qnkb2-ZoqQJ. - The DAV client root is derived from the calendar URL by splitting on
/calendars/:dav_root = NEXTCLOUD_CALDAV_URL.split("/calendars/")[0] + "/"— this produceshttps://cloud.ffw-onza.de/remote.php/dav/, verified against the real server. - An all-day event's
dtstartis a plaindate, notdatetime—isinstance(dtstart, datetime)isFalsefor those (datetime is a subclass of date, so this check is reliable both ways). create_calendar_eventmust never be called by Claude without the user first confirming in the conversation — enforced via system-prompt instruction (see spec's "Sicherheit" section), not a technical gate.- Tool-use intermediate turns (the
tool_use/tool_resultexchange) are never persisted to themessagestable — only the final visible user/assistant pair, matching existing persistence behavior.
Task 1: CalDAV helpers + config + unit tests
Files:
- Modify:
Claude outputs/main.py(imports, config constants,_caldav_calendar,list_upcoming_events,create_event) - Modify:
Claude outputs/requirements.txt(addcaldav,icalendar) - Modify:
Claude outputs/docker-compose.yml(jarvis-api environment) - Test:
Claude outputs/tests/test_calendar.py
Interfaces:
-
Produces:
async def list_upcoming_events(days_ahead: int) -> listreturning[{"summary": str, "start": iso_str, "end": iso_str, "description": str, "all_day": bool}, ...]sorted by start;async def create_event(summary: str, start: str, end: str, description: str = "") -> dictreturning{"summary", "start", "end", "description"}. Consumed by Task 2 (endpoint) and Task 3 (chat tools). -
Step 1: Add dependencies
In Claude outputs/requirements.txt, add two lines:
caldav
icalendar
(Unpinned, like anthropic — these are new enough that a pinned version chosen without checking PyPI risks picking one that doesn't exist; pip resolves the current compatible release at container build time.)
- Step 2: Write the failing tests
Create Claude outputs/tests/test_calendar.py:
import os
import sys
from datetime import datetime, date
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import main
def _fake_component(summary, dtstart_value, dtend_value, description=""):
comp = {}
comp["summary"] = summary
comp["description"] = description
class _Val:
def __init__(self, v):
self.dt = v
comp["dtstart"] = _Val(dtstart_value)
comp["dtend"] = _Val(dtend_value)
def get(key, default=None):
return comp.get(key, default)
fake = MagicMock()
fake.get = get
return fake
def test_list_upcoming_events_maps_timed_and_all_day_events():
timed = MagicMock()
timed.icalendar_component = _fake_component(
"THL FM LS", datetime(2026, 9, 14, 19, 0, 0), datetime(2026, 9, 14, 20, 0, 0)
)
all_day = MagicMock()
all_day.icalendar_component = _fake_component(
"Volksfestwache", date(2026, 9, 19), date(2026, 9, 20)
)
fake_calendar = MagicMock()
fake_calendar.search.return_value = [all_day, timed] # unsorted on purpose
with patch.object(main, "_caldav_calendar", return_value=fake_calendar):
events = main._list_upcoming_events_sync(14)
assert [e["summary"] for e in events] == ["THL FM LS", "Volksfestwache"]
assert events[0]["all_day"] is False
assert events[1]["all_day"] is True
def test_create_event_calls_save_event_with_parsed_dates():
fake_calendar = MagicMock()
with patch.object(main, "_caldav_calendar", return_value=fake_calendar):
result = main._create_event_sync(
"JARVIS Testtermin", "2026-09-20T10:00:00", "2026-09-20T11:00:00", "desc"
)
fake_calendar.save_event.assert_called_once()
_, kwargs = fake_calendar.save_event.call_args
assert kwargs["summary"] == "JARVIS Testtermin"
assert kwargs["dtstart"] == datetime(2026, 9, 20, 10, 0, 0)
assert result["summary"] == "JARVIS Testtermin"
- Step 3: Run tests to verify they fail
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
ssh $SSHOPTS jarvis-core@72.61.186.98 "mkdir -p /tmp/jarvis-test/tests"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" "$OUT/requirements.txt" "$OUT/requirements-dev.txt" jarvis-core@72.61.186.98:/tmp/jarvis-test/
scp $SSHOPTS "$OUT/tests/test_calendar.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_calendar.py -v'"
Expected: FAIL — _caldav_calendar/_list_upcoming_events_sync/_create_event_sync don't exist yet.
- Step 4: Add config constants
In Claude outputs/main.py, change the datetime import line:
from datetime import datetime
to:
from datetime import datetime, timedelta
Add near the top, after the other import lines (after import aiohttp):
import caldav
Add near the other # ============ CONFIG ============ constants:
NEXTCLOUD_CALDAV_URL = os.getenv("NEXTCLOUD_CALDAV_URL")
NEXTCLOUD_USER = os.getenv("NEXTCLOUD_USER")
NEXTCLOUD_APP_PASSWORD = os.getenv("NEXTCLOUD_APP_PASSWORD")
- Step 5: Add the CalDAV helpers
In Claude outputs/main.py, add after get_latest_weather (the last DB helper):
def _caldav_calendar():
dav_root = NEXTCLOUD_CALDAV_URL.split("/calendars/")[0] + "/"
client = caldav.DAVClient(url=dav_root, username=NEXTCLOUD_USER, password=NEXTCLOUD_APP_PASSWORD)
return client.calendar(url=NEXTCLOUD_CALDAV_URL)
def _list_upcoming_events_sync(days_ahead: int) -> list:
calendar = _caldav_calendar()
start = datetime.now()
end = start + timedelta(days=days_ahead)
results = calendar.search(start=start, end=end, event=True, expand=True)
events = []
for result in results:
comp = result.icalendar_component
dtstart = comp.get("dtstart").dt
dtend_prop = comp.get("dtend")
dtend = dtend_prop.dt if dtend_prop else dtstart
events.append(
{
"summary": str(comp.get("summary", "")),
"start": dtstart.isoformat(),
"end": dtend.isoformat(),
"description": str(comp.get("description", "")),
"all_day": not isinstance(dtstart, datetime),
}
)
events.sort(key=lambda e: e["start"])
return events
async def list_upcoming_events(days_ahead: int) -> list:
return await asyncio.to_thread(_list_upcoming_events_sync, days_ahead)
def _create_event_sync(summary: str, start: str, end: str, description: str = "") -> dict:
calendar = _caldav_calendar()
calendar.save_event(
dtstart=datetime.fromisoformat(start),
dtend=datetime.fromisoformat(end),
summary=summary,
description=description,
)
return {"summary": summary, "start": start, "end": end, "description": description}
async def create_event(summary: str, start: str, end: str, description: str = "") -> dict:
return await asyncio.to_thread(_create_event_sync, summary, start, end, description)
- Step 6: Run tests to verify they pass
Re-run the Step 3 command. Expected: 2 passed.
- Step 7: Wire the CalDAV env vars into the deployed container
In Claude outputs/docker-compose.yml, under jarvis-api: environment:, add after API_KEY_ADMIN:
- NEXTCLOUD_CALDAV_URL=https://cloud.ffw-onza.de/remote.php/dav/calendars/jonny/ffw-onza-alle/
- NEXTCLOUD_USER=jonny
- NEXTCLOUD_APP_PASSWORD=${NEXTCLOUD_APP_PASSWORD:-}
Then add the actual password to the deployed .env (not docker-compose.yml, which stays committed-safe with the ${...} reference):
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
ssh $SSHOPTS jarvis-core@72.61.186.98 "grep -q NEXTCLOUD_APP_PASSWORD /home/jarvis-core/jarvis/.env || echo 'NEXTCLOUD_APP_PASSWORD=j9Ywc-9Pnbk-8rQQw-Qnkb2-ZoqQJ' >> /home/jarvis-core/jarvis/.env"
- Step 8: Confirm files saved (no git repo for this project — see Global Constraints)
Task 2: GET /api/v1/calendar/events endpoint
Files:
- Modify:
Claude outputs/main.py(add the endpoint)
Interfaces:
-
Consumes:
list_upcoming_events(Task 1). -
Produces:
GET /api/v1/calendar/events?days=14returning{"events": [...]},503on CalDAV failure. Consumed by Task 4's frontend widget. -
Step 1: Add the endpoint
In Claude outputs/main.py, add after the get_weather endpoint (end of file, before if __name__ == "__main__":):
@app.get("/api/v1/calendar/events", dependencies=[Depends(require_admin_key)])
async def get_calendar_events(days: int = 14):
"""Upcoming events from the FFW-Onza-Alle Nextcloud calendar"""
try:
events = await list_upcoming_events(days)
return {"events": events}
except Exception as e:
logger.error(f"Calendar fetch error: {str(e)}")
raise HTTPException(status_code=503, detail=f"Calendar unavailable: {str(e)}")
- Step 2: Deploy and verify against the live API
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" "$OUT/requirements.txt" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/
scp $SSHOPTS "$OUT/docker-compose.yml" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/docker-compose.yml
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose up -d jarvis-api && sleep 10 && docker logs --tail 20 jarvis-api"
Expected: log ends with Application startup complete. (no traceback — the container reinstalls requirements.txt on every start, so caldav/icalendar get pulled in automatically).
ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-)
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -H 'X-Admin-Key: $ADMIN_KEY' 'http://localhost:8000/api/v1/calendar/events?days=30'; echo"
Expected: JSON with an events array containing real entries (e.g. "THL FM LS", "Volksfestwache" — the same events found during the connectivity check).
- Step 3: Confirm files saved (no git repo for this project — see Global Constraints)
Task 3: Chat tool use (query + confirmed create)
Files:
- Modify:
Claude outputs/main.py(imports, tool definitions,execute_tool,run_chat_completion, rewirechat()) - Test:
Claude outputs/tests/test_chat_tools.py
Interfaces:
-
Consumes:
list_upcoming_events,create_event(Task 1),claude_client,CLAUDE_MODEL(existing). -
Produces:
async def run_chat_completion(claude_messages: list) -> tuple[str, int, int]returning(response_text, output_tokens, total_tokens). Replaces the inlineclaude_client.messages.createcall inchat(). -
Step 1: Write the failing test
Create Claude outputs/tests/test_chat_tools.py:
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
import main
def _text_block(text):
block = MagicMock()
block.type = "text"
block.text = text
return block
def _tool_use_block(name, tool_input, tool_id="tool_1"):
block = MagicMock()
block.type = "tool_use"
block.name = name
block.input = tool_input
block.id = tool_id
return block
def _usage(input_tokens, output_tokens):
usage = MagicMock()
usage.input_tokens = input_tokens
usage.output_tokens = output_tokens
return usage
@pytest.mark.asyncio
async def test_run_chat_completion_without_tool_use():
completion = MagicMock()
completion.stop_reason = "end_turn"
completion.content = [_text_block("Hallo!")]
completion.usage = _usage(10, 5)
main.claude_client = MagicMock()
main.claude_client.messages.create.return_value = completion
text, output_tokens, total_tokens = await main.run_chat_completion([{"role": "user", "content": "Hi"}])
assert text == "Hallo!"
assert output_tokens == 5
assert total_tokens == 15
@pytest.mark.asyncio
async def test_run_chat_completion_executes_tool_and_returns_followup():
first = MagicMock()
first.stop_reason = "tool_use"
first.content = [_tool_use_block("list_calendar_events", {"days_ahead": 7})]
first.usage = _usage(20, 8)
second = MagicMock()
second.stop_reason = "end_turn"
second.content = [_text_block("Naechste Woche steht nichts an.")]
second.usage = _usage(30, 12)
main.claude_client = MagicMock()
main.claude_client.messages.create.side_effect = [first, second]
with patch.object(main, "list_upcoming_events", new=AsyncMock(return_value=[])):
text, output_tokens, total_tokens = await main.run_chat_completion(
[{"role": "user", "content": "Was steht diese Woche an?"}]
)
assert text == "Naechste Woche steht nichts an."
assert output_tokens == 8 + 12
assert total_tokens == 20 + 8 + 30 + 12
assert main.claude_client.messages.create.call_count == 2
Note: this test file needs pytest-asyncio to run async def test_... functions — add it to Claude outputs/requirements-dev.txt in this step (append pytest-asyncio==0.24.0), and create Claude outputs/pytest.ini with:
[pytest]
asyncio_mode = auto
- Step 2: Run tests to verify they fail
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/requirements-dev.txt" "$OUT/pytest.ini" jarvis-core@72.61.186.98:/tmp/jarvis-test/
scp $SSHOPTS "$OUT/tests/test_chat_tools.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_chat_tools.py -v'"
Expected: FAIL — run_chat_completion doesn't exist yet.
- Step 3: Add
import json
In Claude outputs/main.py, add import json next to import logging.
- Step 4: Add the tool definitions and execution dispatcher
Add after the CalDAV helpers from Task 1 (after create_event):
CALENDAR_TOOLS = [
{
"name": "list_calendar_events",
"description": "List upcoming events from the FFW-Onza-Alle calendar within the next N days.",
"input_schema": {
"type": "object",
"properties": {
"days_ahead": {
"type": "integer",
"description": "How many days ahead to look, e.g. 7 for the next week",
}
},
"required": ["days_ahead"],
},
},
{
"name": "create_calendar_event",
"description": (
"Create a new event in the FFW-Onza-Alle calendar. Only call this "
"after the user has explicitly confirmed the event details "
"(title, date, time) in the conversation."
),
"input_schema": {
"type": "object",
"properties": {
"summary": {"type": "string", "description": "Event title"},
"start": {"type": "string", "description": "Start date/time in ISO 8601, e.g. 2026-09-20T10:00:00"},
"end": {"type": "string", "description": "End date/time in ISO 8601, e.g. 2026-09-20T11:00:00"},
"description": {"type": "string", "description": "Optional longer description"},
},
"required": ["summary", "start", "end"],
},
},
]
CALENDAR_ASSISTANT_INSTRUCTIONS = (
"Du hast Zugriff auf den Kalender 'FFW-Onza-Alle' ueber die Tools "
"list_calendar_events und create_calendar_event. Bevor du "
"create_calendar_event aufrufst, frage den Nutzer immer explizit im "
"Klartext nach Bestaetigung der Termindetails (Titel, Datum, Uhrzeit) "
"und rufe das Tool erst auf, nachdem der Nutzer im naechsten "
"Chat-Beitrag zugestimmt hat."
)
async def execute_tool(name: str, tool_input: dict) -> str:
if name == "list_calendar_events":
events = await list_upcoming_events(tool_input.get("days_ahead", 14))
return json.dumps(events)
if name == "create_calendar_event":
result = await create_event(
tool_input["summary"],
tool_input["start"],
tool_input["end"],
tool_input.get("description", ""),
)
return json.dumps(result)
raise ValueError(f"Unknown tool: {name}")
- Step 5: Add
run_chat_completion
Add directly after execute_tool:
async def run_chat_completion(claude_messages: list):
system_prompt = f"{CLAUDE_SYSTEM_PROMPT}\n\n{CALENDAR_ASSISTANT_INSTRUCTIONS}"
completion = await asyncio.to_thread(
claude_client.messages.create,
model=CLAUDE_MODEL,
max_tokens=1024,
system=system_prompt,
tools=CALENDAR_TOOLS,
messages=claude_messages,
)
total_input = completion.usage.input_tokens
total_output = completion.usage.output_tokens
if completion.stop_reason != "tool_use":
response_text = "".join(b.text for b in completion.content if b.type == "text")
return response_text, total_output, total_input + total_output
tool_results = []
for block in completion.content:
if block.type != "tool_use":
continue
try:
result_text = await execute_tool(block.name, block.input)
tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": result_text})
except Exception as e:
tool_results.append(
{"type": "tool_result", "tool_use_id": block.id, "content": str(e), "is_error": True}
)
followup_messages = claude_messages + [
{"role": "assistant", "content": completion.content},
{"role": "user", "content": tool_results},
]
followup = await asyncio.to_thread(
claude_client.messages.create,
model=CLAUDE_MODEL,
max_tokens=1024,
system=system_prompt,
tools=CALENDAR_TOOLS,
messages=followup_messages,
)
total_input += followup.usage.input_tokens
total_output += followup.usage.output_tokens
response_text = "".join(b.text for b in followup.content if b.type == "text")
return response_text, total_output, total_input + total_output
- Step 6: Run tests to verify they pass
Re-run the Step 2 command. Expected: 2 passed.
- Step 7: Rewire
chat()to use it
In Claude outputs/main.py, inside chat(), replace:
completion = await asyncio.to_thread(
claude_client.messages.create,
model=CLAUDE_MODEL,
max_tokens=1024,
system=CLAUDE_SYSTEM_PROMPT,
messages=claude_messages,
)
response_text = "".join(block.text for block in completion.content if block.type == "text")
input_tokens = completion.usage.input_tokens
output_tokens = completion.usage.output_tokens
tokens_used = input_tokens + output_tokens
await save_message(conversation_id, DEFAULT_USER_ID, "assistant", response_text, output_tokens)
with:
response_text, output_tokens, tokens_used = await run_chat_completion(claude_messages)
await save_message(conversation_id, DEFAULT_USER_ID, "assistant", response_text, output_tokens)
- Step 8: Deploy and verify against the live API
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose restart jarvis-api && sleep 10 && docker logs --tail 20 jarvis-api"
Expected: log ends with Application startup complete. (no traceback). Then:
ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-)
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -X POST http://localhost:8000/api/v1/chat -H 'Content-Type: application/json' -H 'X-Admin-Key: $ADMIN_KEY' -d '{\"message\": \"Was steht in den naechsten 30 Tagen im Kalender an?\"}'; echo"
Expected: a natural-language response mentioning real events from the calendar (e.g. "THL FM LS", "Volksfestwache") — proof the tool round-trip actually executed against the live Nextcloud calendar, not a hallucinated answer.
- Step 9: Confirm files saved (no git repo for this project — see Global Constraints)
Task 4: "Nächste Termine" dashboard widget
Files:
- Create:
web/src/components/CalendarWidget.tsx - Modify:
web/src/components/Dashboard.tsx(render it)
Interfaces:
-
Consumes:
apiFetch(existing),GET /api/v1/calendar/events(Task 2). -
Produces:
CalendarWidgetdefault export, no props, rendered insideDashboard. -
Step 1: Create the widget
Create web/src/components/CalendarWidget.tsx:
import { useEffect, useState } from "react";
import { apiFetch } from "../api";
type CalendarEvent = { summary: string; start: string; all_day: boolean };
export default function CalendarWidget() {
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const response = await apiFetch("/api/v1/calendar/events?days=14");
if (!response.ok) {
setError("Kalender nicht verfuegbar");
return;
}
const data = await response.json();
setEvents(data.events);
} catch {
setError("Kalender nicht verfuegbar");
}
}
load();
}, []);
function formatEvent(e: CalendarEvent): string {
const date = new Date(e.start);
const dateStr = date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" });
if (e.all_day) return `${dateStr}: ${e.summary}`;
const timeStr = date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
return `${dateStr} ${timeStr}: ${e.summary}`;
}
return (
<div className="calendar-widget">
<h3>N\u00e4chste Termine</h3>
{error && <p className="error">{error}</p>}
{events && events.length === 0 && <p>Keine Termine in den n\u00e4chsten 14 Tagen.</p>}
{events && events.length > 0 && (
<ul>
{events.map((e, i) => (
<li key={i}>{formatEvent(e)}</li>
))}
</ul>
)}
</div>
);
}
- Step 2: Render it in the Dashboard
In web/src/components/Dashboard.tsx, add the import next to the other imports:
import CalendarWidget from "./CalendarWidget";
Add <CalendarWidget /> right after the closing </table> of the health table, still inside the outer <div className="dashboard">.
- Step 3: Verify the build works
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web"
npm run build
Expected: exits 0.
- Step 4: Deploy
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS"
scp $SSHOPTS -r "$OUT\web\src" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/web/
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose build jarvis-web && docker compose up -d jarvis-web"
- Step 5: End-to-end browser verification
Using the claude-in-chrome browser tools: log in at https://jarvis.mbo-tech-it.de, open Dashboard, confirm "Nächste Termine" lists real events (e.g. "THL FM LS"). Then in Chat, ask "Was steht diese Woche im Kalender an?" and confirm a real-data answer. Then say something like "Leg einen Termin 'JARVIS Verbindungstest' am 25.09.2026 von 14 bis 15 Uhr an" and confirm JARVIS asks for confirmation before creating anything; confirm it, then verify the event actually appears (re-query the widget or the calendar endpoint) — afterwards delete that test event directly against the calendar (reuse the connectivity-check pattern: a one-off caldav script via docker run python:3.11-slim, searching by summary and calling .delete()) so the real organisation calendar stays clean.
- Step 6: Confirm files saved (no git repo for this project — see Global Constraints)