feat(api): scheduled-send queue (/v1/scheduled) + dashboard Scheduled tab - #977
feat(api): scheduled-send queue (/v1/scheduled) + dashboard Scheduled tab#977cyj-git-0825 wants to merge 7 commits into
Conversation
List outbound messages accepted with a future send_at (scheduled_at not null and in the future, delivery_status=accepted), soonest-first, keyset-paginated. Account-scoped, read-only, beta — the account-wide counterpart to /v1/reviews for "what's going to send later". ListScheduled is a sibling store query to ListReviews (which hardcodes status=pending_review), keeping the scheduled and review sets disjoint by construction. No migration: reads the existing messages.scheduled_at. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Regenerate the TS/Python client bases for the new listScheduled operation and add the hand-written ergonomic method client.scheduled.list on both SDKs (Python async + sync). Add the account-tier MCP tool list_scheduled (read-only), wired through McpClient.listScheduled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Held / Scheduled tab switch to the Pending page. The Scheduled tab reads the account-scoped GET /v1/scheduled queue and lists outbound messages queued to send later, soonest-first, with a "Sends <local time>" label; a row expands read-only to the full message (no approve/reject, since a scheduled send is not a hold). Add the shared contract scenario step for /v1/scheduled, exercised by the Go/TS/Python runners. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the new listScheduled operation through the gates the feature commits missed: - TS contract-client: pass sendAt as a Date (SendEmailInput type), not a string (TS2322). - Go stability gates: allowlist ScheduledMessageView.direction as a closed response enum (always outbound), and add listScheduled to the reviewed beta-operation inventory. - docs/api.md: add listScheduled to the Beta operations table + a Scheduled queue section and surface-table row. - Plugin/MCP docs: bump the stated MCP tool count 78->79 (58 admin) in the e2a SKILL and the MCP README/runbook/examples prose. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Editing the e2a SKILL added a tool, so the plugin-version-bump gate now requires a release bump. Bump plugin.meta.json 0.9.5 -> 0.9.6 and its advertised MCP tool count 78 -> 79, regenerate the client + marketplace manifests, and update the packaging/email-evals tests' pinned version and count constants to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Could you please attach a screenshot for the new UI? Thanks! |
|
Question on resource naming and long-term shape: /v1/scheduled and SDK client.scheduled name a state (an adjective), while API resources are generally nouns. Since this endpoint returns messages, have we considered an account-scoped GET /v1/messages with a documented scheduled filter as the long-term model, while retaining the existing agent-scoped GET /v1/agents/{email}/messages? If we go with the v1/messages model, could you assess the pitfalls we may be overlooking? In particular: how it should coexist with the existing agent-scoped list, account-vs-agent auth semantics, filter grammar and stable ordering, response-shape differences, SDK naming and backward compatibility, and query/index performance. A dedicated /v1/scheduled-messages may be the safer narrow beta surface, but I would like to understand the tradeoff before cementing scheduled as a public namespace. |
| AND m.direction = 'outbound' | ||
| AND m.delivery_status = 'accepted' | ||
| AND m.scheduled_at IS NOT NULL | ||
| AND m.scheduled_at > now()` |
There was a problem hiding this comment.
internal/identity/scheduled.go:63 excludes any accepted message whose scheduled_at is already past, on the assumption the test file states directly: a past-scheduled row is "about to fire or already fired." That assumption breaks for the daily-quota deferral in internal/agent/outbound_async.go (ClaimSend, the resource == "messages_day" branch): it releases the claim, leaves delivery_status at accepted, and snoozes the job until next UTC midnight without touching messages.scheduled_at (the only writer is StampScheduledAtTx, called at accept time and at review approval, never on retry or snooze). So a message deferred by the daily cap sits accepted with a stale scheduled_at for up to a day, invisible to both the Held tab (never pending_review) and the new Scheduled tab.
I ran the PR's own TestListScheduled_ReturnsAcceptedFutureSendsSoonestFirst at ba145b2 in a container (postgres:16-alpine + golang:1.25-alpine). It already builds this exact row (pastID: accepted, past scheduled_at) and asserts it gets excluded, which confirms the gap:
go test ./internal/identity/... -run TestListScheduled -v
--- PASS: TestListScheduled_ReturnsAcceptedFutureSendsSoonestFirst (5.33s)
Could be worth surfacing overdue-but-still-accepted rows in this same view (drop the scheduled_at > now() bound, or add a separate overdue section) so a quota-deferred send does not just disappear from the dashboard for the length of the deferral.
There was a problem hiding this comment.
internal/identity/scheduled.go:63excludes any accepted message whosescheduled_atis already past, on the assumption the test file states directly: a past-scheduled row is "about to fire or already fired." That assumption breaks for the daily-quota deferral ininternal/agent/outbound_async.go(ClaimSend, theresource == "messages_day"branch): it releases the claim, leavesdelivery_statusataccepted, and snoozes the job until next UTC midnight without touchingmessages.scheduled_at(the only writer isStampScheduledAtTx, called at accept time and at review approval, never on retry or snooze). So a message deferred by the daily cap sitsacceptedwith a stalescheduled_atfor up to a day, invisible to both the Held tab (neverpending_review) and the new Scheduled tab.I ran the PR's own
TestListScheduled_ReturnsAcceptedFutureSendsSoonestFirstatba145b2in a container (postgres:16-alpine + golang:1.25-alpine). It already builds this exact row (pastID: accepted, pastscheduled_at) and asserts it gets excluded, which confirms the gap:go test ./internal/identity/... -run TestListScheduled -v --- PASS: TestListScheduled_ReturnsAcceptedFutureSendsSoonestFirst (5.33s)Could be worth surfacing overdue-but-still-accepted rows in this same view (drop the
scheduled_at > now()bound, or add a separate overdue section) so a quota-deferred send does not just disappear from the dashboard for the length of the deferral.
@AmirF194 thanks for tracing this so precisely. I verified the path, and you’re right: when the daily cap is hit, the job gets deferred to the next UTC reset but scheduled_at stays unchanged, so an accepted message can become overdue and disappear from the Scheduled view.
I fixed it following your first option: ListScheduled no longer filters out past scheduled_at values. Overdue messages stay in the queue and are shown as Overdue · was due …, while still remaining read-only.
I also added coverage for the future-scheduled and overdue/deferred cases, including ordering, exclusions, and account scoping, and updated the existing test that expected overdue messages to be excluded.
I think there’s a separate product question here around whether we should silently defer a user-specified send_at when we can’t honor it. I’ll capture that separately rather than expanding the scope of this PR.
Also, good reminder for me with CC-generated tests — I’ll make sure we’re testing the underlying state transitions and edge cases, not just the code paths we changed. Thanks again for catching this!
There was a problem hiding this comment.
That matches what I traced, thanks for confirming and adding the coverage.
@jiashuoz Thanks Josh — I spent some more time looking at the trade-off. I think there are two separate questions: what the current use case requires, and whether that is enough to justify introducing an account-scoped Current preference: Option 2For this PR, I think The main consideration is that Option 1 is more than adding a
These are all reasonable investments if we expect several account-wide message views. But for the current use cases I can see, Scheduled appears to be the main additional account-wide operational queue alongside Reviews. I don't yet see enough of a cluster to make the broader abstraction clearly necessary for this PR. You may have better visibility into upcoming use cases here. Longer-term modelI agree that an account-scoped That would treat these as different views over the same message lifecycle rather than separate resources. I don't think we need to commit to that abstraction yet, but I do think the current implementation should avoid making it harder to introduce later. Why Option 2 doesn't lock us into
|
Address PR tokencanopy#977 review: - Rename GET /v1/scheduled -> /v1/scheduled-messages (jiashuoz): the endpoint returns a collection of messages, so the path reads as a noun. operationId listScheduled -> listScheduledMessages; ergonomic SDK resources client.scheduledMessages (TS) / client.scheduled_messages (Py); MCP tool list_scheduled_messages. Regenerated the spec and both SDK bases. Kept the internal query/view (ListScheduled, ScheduledMessageView) and the send-result status "scheduled" unchanged, so a future account-scoped /v1/messages?view=scheduled can reuse the same query and representation without a redesign. - Surface overdue-but-pending sends (AmirF194): a daily-cap deferral at fire time leaves a row accepted with a now-past scheduled_at (only StampScheduledAtTx writes scheduled_at, at accept/approval), which the scheduled_at > now() bound hid from both the Held and Scheduled tabs until it fired. Drop the bound so the send stays visible (soonest-first floats the most overdue to the top); the dashboard flags it with a warn-tone "Overdue" chip instead of "Sends". A genuinely stuck send is still terminated by the send worker's retry horizon. Flipped the store test that had asserted the exclusion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The rename in 725760a missed the machine-checked docs/api.md beta operations list (TestDocumentedBetaOperationsMatchOpenAPI) and the Python resource/coverage assertions (test_resources_exposed, async + sync). Update docs/api.md and the Python SDK tests to the new listScheduledMessages / /v1/scheduled-messages names; also refresh the web comment references. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>



Requirement
Operators need a single, account-wide view of what's going to send later.
Scheduled sending (
send_at, #815 / migration 084) is modeled and shown permessage —
scheduled_atrides on message/review views and renders as a chip —but there is no way to ask the server for the scheduled set as a whole. The
dashboard's Pending page is driven by
GET /v1/reviews, which returns onlypending_reviewholds; a scheduled-but-not-held message isdelivery_status = accepted, so it never appears there and was invisible in the dashboard.Solution
Add a first-class, account-scoped
GET /v1/scheduledendpoint: outboundmessages that are
acceptedwith a futurescheduled_at, soonest-first,keyset-paginated, read-only. Surface it as a "Scheduled" tab on the Pending
page; clicking a row lazy-loads the full message (recipients + body) read-only —
no approve/reject, because a scheduled send is not a hold. The endpoint reuses
the
/v1/reviewsaccount-scoped pattern (cursor infra, scope guard, page shape)and is marked beta.
Approach & the main tradeoff (why a new endpoint, not a filter)
Two options were considered:
scheduledfilter to the existing message list(
GET /v1/agents/{email}/messages, or its AIP-160 filter registry).GET /v1/scheduled(chosen).The existing message list is agent-scoped by its path — it lists one inbox.
The view needed here is account-scoped (every inbox at once), ordered by send
time. Reusing the per-agent list would force the client to fan out over every
agent and merge/paginate in the browser, with no way to order or bound by
scheduled_atserver-side. There is no account-scoped message list to hang afilter on, so option (1) would require inventing one anyway.
GET /v1/reviewsset the precedent: a dedicated account-scoped endpoint (not afilter on
/messages) for exactly this operator-wide view./v1/scheduledisits direct analog. Being beta, it can later fold into a general account-scoped
filterable message list without a breaking change.
At the store layer,
ListReviewscouldn't be reused — its query hardcodesWHERE status = 'pending_review'.ListScheduledis a sibling query(
scheduled_at IS NOT NULL AND scheduled_at > now() AND delivery_status = 'accepted',ORDER BY scheduled_at ASC), keeping the two sets disjoint byconstruction rather than by a runtime flag.
Impact on other components
Purely additive — nothing existing changes behavior:
messages.scheduled_atfrom feat(api): preserve send_at across a review hold instead of discarding it #815).sends — this is a read projection; it never stamps
scheduled_at, enqueuesa job, or cancels one. The send/schedule path (
internal/outboundsend,internal/httpapi/outbound.go) is untouched.line, one route registration.
?id=) andthe sidebar Pending badge (holds-only count) are unchanged. Held-and-scheduled
drafts still show under Held with their existing chip; once approved they move
to Scheduled — no duplication.
From a user's perspective (verified in a running stack)
Ran the server (this branch) + dashboard + Postgres locally, created an agent,
sent two messages with a future
send_at:POST …/messageswithsend_at→202 {status: scheduled}.both, soonest-first, each with a "Sends " chip and
inbox → recipient.GET /v1/scheduledreturns them ordered by send time; once a message sends oris trashed it drops off.
Testing
ListScheduledreturns only accepted+future, soonest-first, account-scoped; excludes past /
unscheduled / already-sent) + the shared contract scenario proven live by
the Go runner.
scheduled.listexercised).
list_scheduledtool, tier map, freeze list).eslint.
ts-contract/python-contract) consume the samescenarios.yamlthe Go runner already passed.Client surface checklist
messages.scheduled_at)client.scheduled.list) + Python SDK (async+sync)list_scheduled)scenarios.yaml, proven via the Go runner