# Scheduled Recalls

Self-recall (scheduled callback) system — allows the agent to schedule future headless work that runs without user interaction.

## Overview

A **recall** is a delayed or recurring task that the agent schedules for itself. When the trigger time arrives, the agent wakes up, reads its self-instruction, and continues working using other tools. The user can watch the execution live via WebSocket streaming.

**Use cases:**
- Check build logs in 30 minutes
- Poll an API every 5 minutes
- Offload heavy multi-tool work without blocking the chat
- Continue work after a server restart

## Architecture

```
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  schedule_recall │────▶│  PostgreSQL     │────▶│  recall loop   │
│  manage_recall   │     │  session_recalls │     │  (poll + fire) │
└─────────────────┘     └─────────────────┘     └─────────────────┘
                                                        │
                                                        ▼
                                               ┌─────────────────┐
                                               │  Agent.run_stream │
                                               │  (headless)      │
                                               └─────────────────┘
                                                        │
                                                        ▼
                                               ┌─────────────────┐
                                               │  WebSocket       │
                                               │  streaming to UI  │
                                               └─────────────────┘
```

## Data model

Table: `session_recalls`

| Column | Type | Description |
|---|---|---|
| `id` | `TEXT PK` | UUID |
| `session_id` | `TEXT FK` | References `sessions(id)` ON DELETE CASCADE |
| `call_type` | `TEXT` | `once`, `recurring`, `immediate` |
| `trigger_at` | `TIMESTAMPTZ` | When to fire (UTC) |
| `interval_seconds` | `INTEGER` | Repeat interval (required for `recurring`) |
| `internal_comment` | `TEXT` | Human-readable note |
| `additional_context_message` | `TEXT` | Self-instruction injected as system message |
| `status` | `TEXT` | `pending`, `fired`, `cancelled` |
| `created_at` | `TIMESTAMPTZ` | Creation time |
| `updated_at` | `TIMESTAMPTZ` | Last mutation |

**Constraints:**
- Only **one pending recall per session** (unique partial index on `session_id WHERE status = 'pending'`).
- `immediate` recalls fire ASAP — `trigger_at` is set to `now()`.

## Tools

### `schedule_recall`

Parameters:

| Param | Type | Required | Description |
|---|---|---|---|
| `call_type` | `string` | yes | `once`, `recurring`, `immediate` |
| `when` | `string` | for once/recurring | Trigger time. Relative formats preferred: `30m`, `2h 15m`, `in 3 hours`, `tomorrow at 09:00`. ISO 8601 only if timezone is known. |
| `timezone_offset` | `string` | optional | `±HH:MM` — required for absolute times if user's timezone is known |
| `interval_seconds` | `integer` | for recurring | Repeat interval in seconds |
| `internal_comment` | `string` | optional | Human-readable note |
| `additional_context_message` | `string` | yes | Self-instruction — write it as a todo for yourself |

**Rules for `additional_context_message`:**
- Must mention specific tools, files, or URLs
- Must read like a todo item, not a chat reminder
- BAD: `"Tell the user that 2 hours have passed"`
- GOOD: `"Read /tmp/build.log with filesystem. If errors, read last 50 lines and report."`

### `manage_recall`

Parameters:

| Param | Type | Required | Description |
|---|---|---|---|
| `action` | `string` | yes | `cancel`, `skip`, `list` |
| `session_id` | `string` | optional | Target session (defaults to current) |

**Actions:**
- `cancel` — deletes the pending recall. Standard pattern before scheduling a new one.
- `skip` — advances a recurring recall by `interval_seconds`. Uses `GREATEST(trigger_at, now)` to avoid scheduling in the past.
- `list` — shows all recalls (pending/fired/cancelled) for the session.

## Scheduler loop

`navi/core/scheduler.py::recall_scheduler_loop()` — background task that:

1. Polls for pending recalls with `trigger_at <= now()`
2. Acquires a semaphore (max 3 concurrent headless runs)
3. For each due recall, calls `_fire_recall()`
4. Sleeps until the next pending recall (or 60 s if queue is empty)

### `_fire_recall` behavior

1. **Defer if busy** — if a websocket run is active for this session (`_runs`), reschedule +60 s
2. **Load session** — if session deleted, mark recall `cancelled`
3. **Set user context** — `current_user_id`, `current_user_role`, `current_user_info` so tools work correctly
4. **Block user messages** — add session to `_busy_sessions` (dict of `session_id → asyncio.Event`)
5. **Register _AgentRun** — so reconnecting clients can replay events
6. **Send `stream_start`** — clients begin showing the streaming message
7. **Run `agent.run_stream(is_recall=True)`** — stream all events to EventBus + WebSocket + replay buffer
8. **On success:** mark `fired` (once) or `reschedule` (recurring), publish `RecallUpdate`
9. **On `MaxIterationsReached`:** treated as success, not failure
10. **On exception:** mark `cancelled` (once) or `reschedule` (recurring)
11. **Finally:** remove from `_busy_sessions` and `_runs`, send `session_sync`

### Stopping a recall

`POST /sessions/{id}/stop` signals the recall's `stop_event` (same mechanism as stopping a normal run).

## WebSocket integration

During a headless recall run, the client receives the same events as a normal chat:

```
stream_start
thinking_delta
tool_started
tool_call
stream_delta
stream_end
session_sync
```

Plus out-of-run `recall_update` events whenever recall state changes.

## UI behavior

- **Banner** — shown in chat header when `chatStore.recall` is set. Displays trigger time + Cancel/Skip buttons.
- **Recall messages** — user messages with `is_recall=true` styled differently (light text, clock badge).
- **Sidebar** — sessions with pending recall show a clock icon. Filter button in header toggles "pending recalls only".
- **Real-time updates** — banner and sidebar update live via `recall_update` WebSocket events.

## API endpoints

See [`api.md`](api.md) for full schemas:

- `GET /sessions/{id}/recall` — get pending recall
- `DELETE /sessions/{id}/recall` — cancel
- `POST /sessions/{id}/recall/skip` — skip next recurring occurrence

## Files

| File | Role |
|---|---|
| `navi/core/scheduler.py` | `RecallScheduler`, DDL, loop, `_fire_recall` |
| `navi/tools/schedule_recall.py` | `ScheduleRecallTool` |
| `navi/tools/manage_recall.py` | `ManageRecallTool` |
| `navi/tools/_internal/time_parser.py` | `parse_when()` — natural language time parser |
| `navi/api/routes/sessions.py` | REST endpoints for recall CRUD |
| `navi/api/websocket.py` | `_busy_sessions`, `_notify_session`, `_on_recall_update` |
| `navi/core/events.py` | `RecallUpdate` event class |
| `navi/llm/base.py` | `Message.is_recall` flag |
