Send Later (scheduled responses)
Send Later queues a reply you write now for delivery at a time you pick, carrying whatever images, PDFs and pasted text were in the composer when you scheduled it. It uses the same time picker as Snooze, is reachable from the local control server, and raises an inbox alert if delivery finally fails.
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) asPOST /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) andsendAt(a strict ISO-8601 time at least 30 seconds in the future) are required, andforce: trueoverwrites an already-scheduled reply (otherwise the route returns409with 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
- 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.
- 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.
- 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); 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.
- 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.)
- 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
endedand 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 (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 re-hydrates the payload, splices the pasted-text chips back into the typed body via spliceTypedTextWithPastedChips, and partitions the images via partitionAttachments 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 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, 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 (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 — the element plus its box at open), or null on purpose (the mobile composer). /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.) 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. 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 (default Ctrl+Shift+L, category session). The time picker is /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, which reads due rows via /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 (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 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.
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 unlinks every path in savedDocPaths so a retry doesn't fight collision suffixes from a failed prior attempt.
Two delivery triggers fire the dispatch:
- 60-second periodic poll (
CHECK_INTERVAL_MS = 60_000) — the safety net. Catches everything eventually. - Event-driven trigger from
updateStatus—process-manager.tslazy-importscheckDueResponseswhenever 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 becausesend-later-service.tsalready importsprocessManagerstatically — a top-level import here would cycle. Errors from the trigger'scheckDueResponses().catch(...)are logged vialogger.warnand swallowed so a DB hiccup can't tank a status transition. Database schema is migration v62 (the original two columns:scheduled_response TEXTbody +scheduled_send_at TEXTISO datetime + filtered indexidx_sessions_scheduled_sendso the due-row query is O(n) on pending schedules only) plus migration v154 (the third column:scheduled_attachments TEXTJSON, NULL on existing rows). IPC handlers:SESSION_SCHEDULE_RESPONSE+SESSION_CANCEL_SCHEDULED_RESPONSEin /src/main/ipc/session-handlers.ts. Store action:scheduleResponse()in /src/renderer/src/stores/session-store.ts. Important invariant (from CLAUDE.md): any sidebar filter that hides "attention" sessions must also exclude rows with ascheduledResponseset — 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()andsoftDeleteOtherBlankSessions()in /src/main/db/queries-sessions/lifecycle.ts treat any row with a non-nullscheduled_responseas 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 — 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 is Send Later's sibling: holds the session instead of a message
- chat-attachments.md — the partition / save / splice pipeline Send Later replays at delivery time
- use-quick-responses.md — combine with Quick Responses if you often send the same canned reply at scheduled times
Last verified 2026-10-06