---
title: Send Later (scheduled responses)
---

# Send Later (scheduled responses)

## What it is

Send Later lets you queue a response to a session for future delivery — the message you wrote _now_ will be typed into the session at the time you picked, **with whatever images, PDFs, text docs, and pasted-text chips were in the composer at scheduling time**. Useful for batch-replying the night before, holding a reply until business hours, or staggering agent work so you don't burn rate-limit tokens all at once. Shares the same time picker UI and phrasing parser as Snooze (so "tomorrow 9 am", "in 2 hours", or a specific datetime all work). If delivery fails (e.g., Omniscio was offline at the scheduled time), Omniscio retries up to three times — after the third failure it gives up, inserts a system message in the session, **and raises a visible inbox alert** so a failed scheduled reply is never silent. If the session's **working folder is missing** (its worktree was cleaned up, or the drive was unmounted / retired), a resume can never succeed, so Omniscio skips the retries, surfaces the same alert immediately, and does **not** resurface the (often archived) session.

> **CLI-reachable (text only).** Send Later is exposed through Omniscio's local control server (`127.0.0.1:19519`) as `POST /session/:id/schedule-response`, so an external script or an AI driving Omniscio over the CLI can queue a scheduled reply. The JSON body is `{ "text": "<message>", "sendAt": "<ISO-8601>", "force"?: true }` — `text` (≤ 500 KB) and `sendAt` (a strict ISO-8601 time at least 30 seconds in the future) are required, and `force: true` overwrites an already-scheduled reply (otherwise the route returns `409` with the existing schedule). It is **text only** in CLI v1: the in-app composer's image / PDF / pasted-text attachments cannot be queued over the CLI. Because delivery injects a real (paid, at delivery time) turn, the route **requires a source session** (`X-AMC-Source-Session-Id`) and is refused without one, exactly like `/message`; success returns `{ "ok": true, "data": { "sessionId", "sendAt" } }`.

## Where to find it

### How to use it

1. **Draft your reply.** Type the message you want sent later into the session's input field just like a normal reply. Attach files (images, PDFs, text docs) and paste blocks the same way you would for an immediate send — the chip strip above the textarea is part of what gets scheduled.
2. **Open the scheduler.** When your draft has text the Send button becomes a **split button** with a small **▾ caret** on its right. Four ways in: **click the ▾ caret** → a small **send-options menu** opens (Send Later · Send as aside) — choose **Send Later**; or **press and hold the Send button** (~½ second, mouse or touch) for the same menu; or press **Ctrl+Shift+L**, or choose **Send Later** from the session's overflow (⋯) menu (or its pinned header button, if you pinned it) — these open the picker directly. The Snooze palette opens in "send-later" mode — same pill UI but labeled for scheduling a send instead of a pause — and on desktop it opens **right beside what you used**: just above the chat box for the ▾ menu, the hold and Ctrl+Shift+L (so your draft stays visible), just under the header button for the ⋯ menu or a pinned Send Later, and next to the scheduled message for **Reschedule**. It stays fully inside the window and re-fits as its list changes; on a phone it stays at the top, clear of the on-screen keyboard.
3. **Pick a time.** Type a natural-language duration ("tomorrow 9am", "in 90 minutes", "Friday 2pm") or pick from the preset pills. You can also punch in an exact datetime. Confirm. A typed time is always what **Enter** picks. While the agent is **working**, the picker also offers **"After the agent finishes"** as its first row (see [queue-a-message.md](queue-a-message.md)); it is never offered on an idle or brand-new session, where it would just send right away — so Send Later on a session you haven't started yet always waits for the time you pick.
4. **See it queued.** The session shows a small "scheduled" badge with the send time; the session row in the sidebar marks it with a scheduled-response indicator. The original message stays visible in your input in case you want to cancel or edit. Scheduling also **advances you to the next session** exactly like a normal reply, snooze, or archive — it follows your post-send navigation setting, so batch-scheduling replies walks you straight down your queue instead of stranding you on the one you just handled. (This runs the same post-send navigation as every other action; the timing contract lives in [/.claude/memory/contracts/inbox-navigation-contract.md](/.claude/memory/contracts/inbox-navigation-contract.md).)
5. **Cancel or replace if needed.** Click the scheduled badge → **Cancel** removes the scheduled response and restores the draft — including the attachment chips so a re-schedule doesn't lose them. To pick a different time instead, click **Reschedule** on the scheduled message: the picker opens beside it with the same text and attachments, and the new time replaces the old one. If you type a _new_ reply and hit Send before the scheduled time, the old one is cancelled automatically.

## How it behaves

### What happens at delivery time, by session status

The scheduled time arrives and Omniscio delivers. **Your scheduled message itself appears in the chat as a normal operator message** — the same way your manual sends do — followed by the system confirmation. You always see what got sent; user messages are never invisible. What "deliver" means depends on the session's status at that exact moment:

- **needs_you / ready** — Sent into the existing session like a normal reply. Your message appears in chat, then system chat says "Scheduled response sent".
- **running** (agent is actively generating) — Sent _mid-stream_, just like if you'd typed the message and hit Send during a busy turn. The CLI may reuse the persistent process if the in-flight turn happens to be done, or it may kill and respawn with the new prompt. Your message appears in chat, then system chat says "Scheduled response sent (interrupted in-progress turn)" so a cut-off turn isn't a silent surprise. The trade-off: your message actually delivers on time instead of waiting forever for the agent to finish.
- **ended / error / stalled** — Resumed via a fresh launch with the scheduled text as the first prompt (like clicking Continue). Your message appears in chat, then system chat says "Scheduled response sent".
- **archived** — Auto-unarchived first, then resumed via launch. (Exception: if the session's working folder is gone, delivery fails fast with an alert and the session is **left archived**, not resurfaced — see the failure handling in "How it works".)
- **snoozed** — Snooze is cleared first, then delivered per the underlying status above.
- **paused** — Auto-unpaused first (DB flips to `ended`), then resumed via launch. The reasoning: you scheduled the message _before_ pausing, so honour the schedule rather than letting it rot.
- **starting / terminating** — Skipped this tick. These are sub-second transient states; the next periodic check (≤60 s) or the event-driven trigger (see below) catches them once they settle.

**Special case — a session you _stopped_ (user-closed).** If you scheduled a reply and then **stopped** that session (or it was cascade-stopped by stopping its controller), what happens depends on **who** scheduled the reply — because a stopped session is otherwise kept closed so automation can't quietly resurrect it:

- **You scheduled it** → it **reopens and delivers**, exactly like typing the message now would. Stopping a session doesn't void a message _you_ deliberately queued — your most recent action wins. (Under the hood the stopped session is settled to `ended` and resumed via a fresh launch with your scheduled text as the prompt.)
- **Inbox Pilot / automation scheduled it** → it is **not** delivered. The session is finalized (archived) and the dead reply cleared, so a run you stopped on purpose can't crawl back to life on its own.

This mirrors an immediate send: typing a message into a stopped session reopens it too, because a genuine user turn re-engages the session. The split is by the recorded schedule source, so only Inbox-Pilot-scheduled replies are held back. Invariants live in [/.claude/memory/contracts/authoritative-user-close-contract.md](/.claude/memory/contracts/authoritative-user-close-contract.md) (`never-stranded-active` / `user-scheduled-send-later-reopens`).

### How attachments are preserved

Pre-v154 the scheduler only stored `scheduled_response` text + `scheduled_send_at`, so any images / PDFs / text docs / pasted-text chips in the composer at scheduling time silently disappeared and only the bare text delivered. Since migration v154 the `sessions` table carries a third column, `scheduled_attachments` (JSON `TEXT`), that captures the same `AttachmentPayload` shape (`{ images: ImageAttachment[], pastedTexts: PastedTextAttachment[] }`) the live `SESSION_SEND_RESPONSE` path uses. NULL on existing rows means "no attachments" (equivalent to the prior behavior); on a Send Later, the renderer rebuilds the payload from `pendingImages` + `pendingPastedTexts` and ships it through the scheduling IPC alongside the text.

At delivery time `prepareScheduledAttachments` in [/src/main/services/send-later-service.ts](/src/main/services/send-later-service.ts) re-hydrates the payload, splices the pasted-text chips back into the typed body via [`spliceTypedTextWithPastedChips`](/src/shared/pasted-text.ts), and partitions the images via [`partitionAttachments`](/src/main/services/attachment-partition.ts) into the same three buckets `session-service.sendResponse` uses (images → inline `image` blocks, PDFs → `document` blocks **plus** workdir save, text docs → workdir save + path injection). The operator message persisted to `conversation_messages` carries the same `pasted_attachments` range metadata that a manual send would, so the chat-history chip overlay paints correctly when you scroll back. See [chat-attachments.md](chat-attachments.md) for the partition / save / splice contract — Send Later is the same pipeline, just deferred.

Cancel restores the draft including chips: `SESSION_CANCEL_SCHEDULED_RESPONSE` reads the `scheduled_attachments` JSON before clearing the row and emits a `restore-session-input` event whose payload now includes the rebuilt `images[]` and `pastedTexts[]` arrays. The renderer rehydrates `pendingImages` and `pendingPastedTexts` so the chip strip reappears in the composer exactly as it was when the user scheduled.

## For agents

### How it works

**Three entry points, one event.** A press-and-hold (~500 ms) on the Send button — [/src/renderer/src/hooks/useLongPressActivate.ts](/src/renderer/src/hooks/useLongPressActivate.ts), built on Pointer Events so one path covers mouse + touch + pen; on pointerdown it takes **pointer capture** of the button, and the button carries `touch-action: none` (the `.long-press-hold` utility), so a small finger-drift or scroll-intent on touch can't fire `pointerleave`/`pointercancel` and kill a legitimate hold (this is what makes the gesture reliable on a phone — a desktop mouse never drifts; see the contract's **I7**) — opens a small **send-options menu** (`MenuShell`) — the same menu a plain click on the visible **▾ caret** beside the plane opens — whose **Send Later** item, the `Ctrl+Shift+L` shortcut, the session overflow menu (and its pinned header button), the scheduled message's **Reschedule** and the mobile draft composer all open the picker through ONE typed opener, `dispatchSendLater` in [/src/renderer/src/lib/snooze-events.ts](/src/renderer/src/lib/snooze-events.ts) (a guard test refuses a raw `SEND_LATER_EVENT` anywhere else). Its `anchor` field is required: the control the picker opens beside ([/src/renderer/src/lib/palette-anchor.ts](/src/renderer/src/lib/palette-anchor.ts) — the element plus its box at open), or `null` on purpose (the mobile composer). [/src/renderer/src/components/ui/CommandPaletteShell.tsx](/src/renderer/src/components/ui/CommandPaletteShell.tsx) places an anchored card below the control, or above it when below would run off the window, right edges lined up, through `containToViewport`, re-fits it while open (card or control resized, window resized, or something scrolled under the control) and leaves it in place while it fades out on close (contract **I10**). (The menu's other item, **Send as aside**, fires a one-shot aside instead — see [asides.md](asides.md).) The hold also suppresses the synthesised click so it doesn't _also_ send (with a 700 ms self-clearing backstop so a stale flag can't eat the next real click); a matching one-shot guard stops that same post-hold click from dismissing the just-opened menu. The former clock split-button and its `showSendLaterButton` setting were removed in favour of the always-on gesture; invariants are locked in [/.claude/memory/contracts/send-later-longpress-contract.md](/.claude/memory/contracts/send-later-longpress-contract.md). A narrow **▾ caret button** renders to the right of the Send (paper-plane) button — a **split button** — as the visible, clickable affordance for the otherwise-hidden gesture: clicking it opens the same send-options menu the hold does (`openSendMenuFromCaret`, without the hold path's close-guard, since a plain click can't self-close the menu). It replaced the old decorative corner-fold, which read as decoration and left the options undiscovered (an owner decision, 2026-08-23). The plane + caret share one wrapper (`rounded-lg overflow-hidden` clips both to one radius; a thin `border-l` divides them), and the caret is gated on the same `longPressEnabled` value (`isolatedComposer ? composerHasContent : Boolean(responseText.trim())`) that arms the hook, so it appears only when the options would actually do something (the draft has text), never on an empty or attachment-only draft. It adds no setting or affordance-registry entry, so the removed clock split-button stays gone. The button also carries `aria-keyshortcuts="Control+Shift+L"` so screen-reader users discover Send Later.

The keyboard shortcut `sendLater` is registered in [/src/shared/keybindings.ts](/src/shared/keybindings.ts) (default Ctrl+Shift+L, category `session`). The time picker is [/src/renderer/src/components/ui/SnoozePalette.tsx](/src/renderer/src/components/ui/SnoozePalette.tsx) — same component as Snooze, with a `mode: 'send-later'` prop that flips the labels and dispatches to the scheduling IPC instead of the snooze one. The backend service is [/src/main/services/send-later-service.ts](/src/main/services/send-later-service.ts), which reads due rows via [/src/main/db/queries-sessions/scheduled-response.ts](/src/main/db/queries-sessions/scheduled-response.ts)'s `listDueScheduledResponses()` and dispatches them via `processDueSession`. The status-based dispatch table is in the section above ("What happens at delivery time, by session status"). **A user-closed (stopped) session is split by schedule source _before_ the status dispatch:** a reply stamped `scheduledResponseSource === 'inbox-pilot'` on a `user_closed_at` row is finalized (`finalizeClosedSession` — terminate any live process, force-archive, clear the dead reply; never delivered), while any other source — a genuine user send (`'user'` / null / legacy) — instead clears the marker (`clearUserClosedMarker`), settles the stale status to `ended` (`updateSessionStatus`, done _after_ the project lookup so a since-deleted project can't strand the row), and falls through to the normal launch path — parity with an immediate send, which re-engages a closed session the same way (`user-message-router.ts`). See [/.claude/memory/contracts/authoritative-user-close-contract.md](/.claude/memory/contracts/authoritative-user-close-contract.md) (`never-stranded-active` / `user-scheduled-send-later-reopens`). On the `needs_you / ready / running` paths it goes through `processManager.writeToStdin` — the same code that handles your manual sends — which persists the operator message in `conversation_messages`; the service then emits `SESSION_OUTPUT` with `source: 'operator'` so the message renders in live chat. On the `ended / error / stalled / archived / paused` paths it goes through `processManager.launch` with the scheduled text as the prompt (paused first calls `queries.unpauseSession` and emits a `SESSION_STATUS_CHANGED` push so the UI tracks the flip to `ended`); after launch resolves successfully the service persists the operator message via `queries.addMessage(sessionId, 'operator', text, false, undefined, savedAttachments, undefined, undefined, undefined, pastedAttachmentsForDb)` (10-arg signature so the operator row carries chips + range metadata) and emits the matching `SESSION_OUTPUT` push, in that order, _before_ the "Scheduled response sent" system message — so chat ordering is operator-then-system. The persist+emit happens after launch succeeds (not before) so a launch failure doesn't leave a duplicate operator message when the next 60-second tick retries. On success the service clears the `scheduled_response`, `scheduled_send_at`, AND `scheduled_attachments` columns in one UPDATE. If delivery fails the service retries up to `MAX_RETRIES = 3`; after the third failure `handleFailure` inserts a system message (`"Scheduled response could not be sent after 3 attempts and was cancelled."`), clears the row, **and raises a deduped inbox alert** via [`raiseSendLaterFailureAlert`](/src/main/services/send-later-failure-alert.ts) so the failure is visible outside the (possibly hidden) session. **Missing-workdir fast-path:** for a resume-bound delivery (`ended` / `error` / `stalled` / `archived` / `paused`, or a user-closed reopen) `processDueSession` first calls `missingSessionWorkDir` — if the working folder is gone it surfaces the same alert + system message, clears the schedule, and returns `'undeliverable'` **before** any un-archive / un-pause / reopen mutation, so a doomed delivery never burns the retry budget or resurfaces a parked session (a live `writeToStdin` session is excluded — its respawn failure is caught by the give-up alert). Invariants: [/.claude/memory/contracts/send-later-delivery-contract.md](/.claude/memory/contracts/send-later-delivery-contract.md).

The 9-arg `processManager.writeToStdin(sessionId, prompt, isAutoResponse, attachments, displayText, images?, metadata?, documents?, pastedAttachments?)` and 9-arg `processManager.launch(sessionId, projectId, workDir, prompt, cliSessionId?, ..., images?, documents?)` calls thread the partitioned arrays through to NDJSON write the same way `session-service.sendResponse` does — so once `prepareScheduledAttachments` returns, the rest of the dispatch is the same code path manual sends use. Failure cleanup mirrors `sendResponse` too: if `writeToStdin` throws or returns null, the service best-effort `unlink`s every path in `savedDocPaths` so a retry doesn't fight collision suffixes from a failed prior attempt.

Two delivery triggers fire the dispatch:

1. **60-second periodic poll** (`CHECK_INTERVAL_MS = 60_000`) — the safety net. Catches everything eventually.
2. **Event-driven trigger from `updateStatus`** — `process-manager.ts` lazy-imports `checkDueResponses` whenever a session leaves a busy status (`running` / `starting` / `terminating`) for a deliverable one (`needs_you` / `ready` / `ended` / `error` / `stalled`). Cuts the deliver-after-status-change latency from up to 60 s down to ~1 microtask. Lazy import is required because `send-later-service.ts` already imports `processManager` statically — a top-level import here would cycle. Errors from the trigger's `checkDueResponses().catch(...)` are logged via `logger.warn` and swallowed so a DB hiccup can't tank a status transition. Database schema is migration v62 (the original two columns: `scheduled_response TEXT` body + `scheduled_send_at TEXT` ISO datetime + filtered index `idx_sessions_scheduled_send` so the due-row query is O(n) on pending schedules only) plus migration v154 (the third column: `scheduled_attachments TEXT` JSON, NULL on existing rows). IPC handlers: `SESSION_SCHEDULE_RESPONSE` + `SESSION_CANCEL_SCHEDULED_RESPONSE` in [/src/main/ipc/session-handlers.ts](/src/main/ipc/session-handlers.ts). Store action: `scheduleResponse()` in [/src/renderer/src/stores/session-store.ts](/src/renderer/src/stores/session-store.ts). **Important invariant** (from CLAUDE.md): any sidebar filter that hides "attention" sessions must _also_ exclude rows with a `scheduledResponse` set — otherwise a queued-but-not-yet-sent session can show up in the wrong bucket. The same "not blank" rule applies to the new-session-reuse path: `findBlankSession()` and `softDeleteOtherBlankSessions()` in [/src/main/db/queries-sessions/lifecycle.ts](/src/main/db/queries-sessions/lifecycle.ts) treat any row with a non-null `scheduled_response` as having pending work, so clicking the "+" button while a scheduled response is queued creates a fresh session instead of bumping you back to the queued one and never silently soft-deletes the queued sibling.

## Related

- [queue-a-message.md](queue-a-message.md) — the multi-message sibling: the picker's "After the agent finishes" queues messages that send on turn-completion (not a clock), one at a time, without interrupting
- [snooze-a-session.md](snooze-a-session.md) — snooze is Send Later's sibling: holds the session instead of a message
- [chat-attachments.md](chat-attachments.md) — the partition / save / splice pipeline Send Later replays at delivery time
- [use-quick-responses.md](use-quick-responses.md) — combine with Quick Responses if you often send the same canned reply at scheduled times
