---
title: "Calendar events created via agent chat do not refresh the calendar UI"
date: 2026-06-29
category: docs/solutions/ui-bugs/
module: calendar
problem_type: ui_bug
component: service_object
symptoms:
  - "Calendar event created via agent chat is persisted to DB but does not appear in the calendar UI tab"
  - "Calendar tab must be manually reloaded to see newly created events"
  - "No calendar_event_created WebSocket message is emitted on agent-driven creation"
  - "CalendarPanel.vue only calls store.loadEvents() in onMounted; tab switches do not re-mount the component"
root_cause: missing_workflow_step
resolution_type: code_fix
severity: medium
tags: [calendar, websocket, ui-refresh, agent-driven, pinia, broadcast, notify-callback]
---

# Calendar events created via agent chat do not refresh the calendar UI

## Problem
A user creates a calendar reminder through chat with the agent; the event is persisted to the SQLite DB successfully, but opening the calendar tab in the UI does not show the newly created event. The calendar view never learns about agent-driven creations until the user manually refreshes the page.

## Symptoms
- Agent chat confirms event creation (and the row is present in the calendar DB), but the calendar tab still shows the previous event list.
- Switching to the calendar tab immediately after creation shows nothing new.
- A full page refresh is required to see the event — proving the data is correct, only the live UI update is missing.

## What Didn't Work
- **Suspected a missing frontend handler.** Initial instinct was that the calendar store had no case for `calendar_event_created`. A grep over `stores/calendar.ts` disproved this: `handleWsEvent` already has a `calendar_event_created` branch at line 209 that pushes the event into the reactive `events` array. The handler was never the problem.
- **Suspected the chat store didn't dispatch calendar events.** The next hypothesis was that the chat WS router swallowed `calendar_*` messages. Inspection of `stores/chat.ts:1860-1868` showed it already forwards `calendar_*` events (including `calendar_event_created`) to `_getCalendarStore().handleWsEvent(data)`. The dispatch wiring exists end-to-end.
- **Suspected the calendar panel didn't reload on tab switch.** This is a real latent issue (`CalendarPanel.vue:205` only calls `store.loadEvents()` in `onMounted`, and CalendarTab lives inside QuadrantPanel's slot in `AgentLayout.vue:66-84`, so tab switching does not re-mount the component) — but it is not the root cause of the agent-creation miss. Even a freshly mounted panel would still rely on a WS push for live updates; the actual gap was upstream.

In every case the frontend side of the chain was complete. The gap was backend-only: `CalendarService.create_event` never emitted the WS message in the first place.

## Solution
Three minimal edits, all threading an existing WebSocket fan-out closure into `CalendarService` so `create_event` can broadcast `calendar_event_created` to the user's open chat tabs.

**Edit 1 — `src/agentkit/calendar/service.py:84-95` — accept an optional `notify_callback`:**

```python
def __init__(
    self,
    db_path: str | Path | None = None,
    auth_db_path: str | Path | None = None,
    notify_callback: Callable[[str, dict[str, object]], Awaitable[None]] | None = None,
) -> None:
    self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
    self.auth_db_path = Path(auth_db_path) if auth_db_path is not None else DEFAULT_AUTH_DB_PATH
    # Optional WS broadcast callback — wired by app.py so create_event
    # can push `calendar_event_created` to the user's open chat tabs
    # without the service depending on the portal module.
    self._notify = notify_callback
```

The corresponding import was added at the top of the file:

```python
from collections.abc import Awaitable, Callable
```

**Edit 2 — `src/agentkit/calendar/service.py:183-199` — best-effort broadcast before returning from `create_event`:**

```python
logger.info(f"Created event {event.id} ({title}) for user {user_id}")
# Broadcast to the user's open chat tabs so the calendar view
# refreshes in real time without a manual reload. Best-effort:
# WS delivery failure must not roll back the successful insert.
if self._notify is not None:
    try:
        await self._notify(
            user_id,
            {"type": "calendar_event_created", "data": {"event": event.to_dict()}},
        )
    except Exception:
        logger.warning(
            "calendar_event_created broadcast failed for event %s",
            event.id,
            exc_info=True,
        )
return event
```

**Edit 3 — `src/agentkit/server/app.py:435-448` — reorder the closure definition before `CalendarService` instantiation and inject it:**

```python
await init_calendar_db()

# Wire portal WebSocket fan-out so calendar events reach the user's
# open chat tab(s) in real time. Shared by CalendarService (for
# create_event broadcasts) and ReminderScheduler (for reminders).
async def _calendar_ws_sender(user_id: str, message: dict[str, object]) -> None:
    await portal.send_to_user(user_id, message)

cal_service = CalendarService(notify_callback=_calendar_ws_sender)
app.state.calendar_service = cal_service

calendar_scheduler = ReminderScheduler(
    dispatcher=ReminderDispatcher(ws_sender=_calendar_ws_sender)
)
```

The `_calendar_ws_sender` closure and `portal.send_to_user` already existed — only the ordering changed (the closure must be defined before `CalendarService` is constructed) and the callback was threaded into the service.

## Why This Works
- **The frontend chain was already complete.** WS arrival → chat store dispatch (`chat.ts:1866`) → calendar store `handleWsEvent` (`calendar.ts:209`) → push into the `events` array → reactive UI update. The only missing link was the backend emit, which Edit 2 adds. Once the message reaches the portal, the rest fires automatically.
- **`CalendarService` stays decoupled from the portal module.** The service takes a `notify_callback` rather than importing `portal` directly, preserving layering. `app.py` is the composition root that wires the closure.
- **Shared broadcast channel.** Reusing the existing `_calendar_ws_sender` closure means `CalendarService` and `ReminderScheduler` fan out through the same `portal.send_to_user` path. Mirrors the pattern already used by sync providers (which broadcast `calendar_sync_conflict` via the same `notify_callback` injection shape).
- **Best-effort broadcast preserves the audit trail.** WS delivery failure (user offline, no open tab, transient portal error) is logged at warning level but does not roll back the successful DB insert. The event is durable; the user just doesn't get a live refresh for that single creation, which is acceptable — the next manual `loadEvents()` will show it.
- **Covers both creation paths.** Both the agent tool path (`tools/calendar_tool.py` → `CalendarService.create_event`) and the REST route path (`server/routes/calendar.py` → `CalendarService.create_event`) are thin wrappers over the service, so both now broadcast.

## Prevention
- **Trace WS event chains end-to-end.** When adding a new WS event type the frontend should react to, trace the full chain: backend emit → portal broadcast → chat store dispatch → domain store handler → UI. A handler on the frontend with no matching emit on the backend is a silent dead path — exactly this bug. The reverse (an emit with no handler) at least logs an unhandled-event warning.
- **Default to the `notify_callback` injection pattern for state-mutating services.** Any service whose mutations the frontend renders should accept an optional broadcast callback wired at the composition root. `CalendarService` now has it for `create_event`; the same shape should be extended to `update_event` and `delete_event` if live multi-tab consistency becomes a requirement.
- **Regression tests guard the fix:**
  - `tests/unit/calendar/test_service.py::test_create_event_broadcasts_via_notify_callback` — verifies the callback fires exactly once with the correct payload shape (`{"type": "calendar_event_created", "data": {"event": ...}}`).
  - `tests/unit/calendar/test_service.py::test_create_event_without_callback_does_not_raise` — verifies the default (no callback) path still works for existing callers that don't pass `notify_callback`.
- **Separate latent fragility — out of scope.** `CalendarPanel.vue` only calls `store.loadEvents()` in `onMounted`; switching tabs does not re-mount the component (it lives in QuadrantPanel's slot), so the panel never reloads after initial mount. The WS broadcast fix papers over this for the agent-creation case, but a future hardening would be to also reload on tab activation. Tracked separately; not part of this fix.

## Verification
- 132 existing calendar unit tests pass (no regressions).
- 2 new regression tests pass.
- `ruff check src/agentkit/calendar/service.py src/agentkit/server/app.py` clean.
- Manual end-to-end verification in the GUI pending (user will test).

## Related Issues
This is the **third** distinct root cause documented for the same symptom ("agent creates calendar event → UI does not show it"). When investigating this symptom, check all three:

1. [calendar-capability-and-ui-fixes.md](../logic-errors/calendar-capability-and-ui-fixes.md) — `CalendarTool` built `reminder_rules` but dropped them in `create_event`, and the default agent was created before `CalendarTool` registration so the LLM never saw the tool.
2. [jwt-secret-dev-mode-user-id-mismatch.md](../integration-issues/jwt-secret-dev-mode-user-id-mismatch.md) — JWT secret unset in dev mode → `user_id=None` in queries vs LLM-hallucinated `user_id` in DB rows.
3. **This doc** — `CalendarService.create_event` had no `notify_callback` and never broadcast `calendar_event_created`.
