Skip to content

feat(api): scheduled-send queue (/v1/scheduled) + dashboard Scheduled tab - #977

Open
cyj-git-0825 wants to merge 7 commits into
tokencanopy:mainfrom
cyj-git-0825:feat/v1-scheduled-queue
Open

feat(api): scheduled-send queue (/v1/scheduled) + dashboard Scheduled tab#977
cyj-git-0825 wants to merge 7 commits into
tokencanopy:mainfrom
cyj-git-0825:feat/v1-scheduled-queue

Conversation

@cyj-git-0825

Copy link
Copy Markdown
Contributor

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 per
message
scheduled_at rides 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 only
pending_review holds; a scheduled-but-not-held message is delivery_status = accepted, so it never appears there and was invisible in the dashboard.

Solution

Add a first-class, account-scoped GET /v1/scheduled endpoint: outbound
messages that are accepted with a future scheduled_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/reviews account-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:

  1. Add a scheduled filter to the existing message list
    (GET /v1/agents/{email}/messages, or its AIP-160 filter registry).
  2. A dedicated account-scoped 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_at server-side. There is no account-scoped message list to hang a
filter on, so option (1) would require inventing one anyway.

GET /v1/reviews set the precedent: a dedicated account-scoped endpoint (not a
filter on /messages) for exactly this operator-wide view. /v1/scheduled is
its direct analog. Being beta, it can later fold into a general account-scoped
filterable message list without a breaking change.

At the store layer, ListReviews couldn't be reused — its query hardcodes
WHERE status = 'pending_review'. ListScheduled is 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 by
construction rather than by a runtime flag.

Impact on other components

Purely additive — nothing existing changes behavior:

  • No new migration (reads the existing messages.scheduled_at from feat(api): preserve send_at across a review hold instead of discarding it #815).
  • No change to any existing endpoint, and no change to when/whether a message
    sends
    — this is a read projection; it never stamps scheduled_at, enqueues
    a job, or cancels one. The send/schedule path (internal/outboundsend,
    internal/httpapi/outbound.go) is untouched.
  • Shared-file edits are one-liners: a cursor constant, a deps field, one wiring
    line, one route registration.
  • Dashboard: default tab is still "Held", so existing deep links (?id=) and
    the 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 …/messages with send_at202 {status: scheduled}.
  • Pending page shows a Held / Scheduled tab switch; the Scheduled tab lists
    both, soonest-first, each with a "Sends " chip and
    inbox → recipient.
  • Clicking a row expands the full email body read-only (no approve/reject).
  • GET /v1/scheduled returns them ordered by send time; once a message sends or
    is trashed it drops off.

Testing

  • Go: unit (scope guard, pagination, not-implemented) + DB (ListScheduled
    returns only accepted+future, soonest-first, account-scoped; excludes past /
    unscheduled / already-sent) + the shared contract scenario proven live by
    the Go runner
    .
  • TS SDK: build + 262 unit + contract-coverage audit (scheduled.list
    exercised).
  • Python SDK: 235 unit + mypy clean (async + sync).
  • MCP: 332 tests (new list_scheduled tool, tier map, freeze list).
  • Web: tsc + 44 Jest (tab render, empty state, click-to-expand read-only) +
    eslint.
  • Gate: SDK operation coverage 75/75.
  • CI-only live jobs (ts-contract / python-contract) consume the same
    scenarios.yaml the Go runner already passed.

Client surface checklist

  • Go handler + tests / no migration (reuses messages.scheduled_at)
  • OpenAPI spec + generated TS/Python types
  • TS SDK (client.scheduled.list) + Python SDK (async+sync)
  • MCP tool (list_scheduled)
  • CLI — N/A (the sibling review queue has no CLI command; kept consistent)
  • Web dashboard — Pending "Scheduled" tab
  • Contract scenario in scenarios.yaml, proven via the Go runner

cyj-git-0825 and others added 5 commits August 30, 2026 22:55
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>
@jiashuoz

jiashuoz commented Sep 1, 2026

Copy link
Copy Markdown
Member

Could you please attach a screenshot for the new UI? Thanks!

@jiashuoz

jiashuoz commented Sep 1, 2026

Copy link
Copy Markdown
Member

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.

Comment thread internal/identity/scheduled.go Outdated
AND m.direction = 'outbound'
AND m.delivery_status = 'accepted'
AND m.scheduled_at IS NOT NULL
AND m.scheduled_at > now()`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@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!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That matches what I traced, thanks for confirming and adding the coverage.

@cyj-git-0825

cyj-git-0825 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

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.

@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 /v1/messages surface now.

Current preference: Option 2

For this PR, I think GET /v1/scheduled-messages is the safer narrow beta surface.

The main consideration is that Option 1 is more than adding a scheduled filter to the existing messages API. The current /v1/agents/{email}/messages endpoint is agent-scoped, while Scheduled needs to be account-scoped. So an account-scoped /v1/messages would introduce several decisions up front:

  • Scope/auth: define how account-level access relates to the existing agent-level access.
  • Filtering/views: define the semantics and grammar for account-wide message views.
  • Ordering/pagination: establish stable ordering and cursor semantics across messages from multiple agents.
  • Response shape: decide whether the account-level representation is identical to, or a superset of, the existing agent-scoped message representation.
  • SDK/API compatibility: introduce a new collection and decide how it coexists with the existing agent-scoped list and future SDK methods.
  • Performance: establish the appropriate account-wide indexes/query patterns as the collection grows.

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 model

I agree that an account-scoped /v1/messages could be a cleaner long-term model if more account-wide views emerge, for example:

/v1/messages?view=scheduled
/v1/messages?view=review
/v1/messages?view=failed
/v1/messages?view=bounced
/v1/messages?view=sent

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 scheduled as a resource

The current implementation keeps Scheduled at the query/view layer rather than introducing a separate scheduled resource:

  • ListScheduled(...) queries the existing message data with the scheduled predicate, scheduled_at ASC ordering, and (scheduled_at, id) keyset pagination.
  • ScheduledMessageView is a message-oriented summary rather than a scheduled-specific resource model.
  • There is no new persistence model or delivery state associated with the endpoint.

So if an account-scoped /v1/messages is introduced later, view=scheduled could reuse the same underlying query semantics and representation rather than requiring a redesign of the scheduled data model.

The migration could then be incremental: introduce /v1/messages?view=scheduled, keep /v1/scheduled-messages as a compatibility surface during the beta period, and eventually make the unified endpoint canonical if the broader model proves useful.

Proposed approach

For this PR, I'd use GET /v1/scheduled-messages rather than /v1/scheduled. This addresses the resource-naming concern while keeping the surface narrow.

If additional account-wide message views emerge, that would be a stronger signal to introduce /v1/messages and define the broader view/filter model at that point.

So the trade-off seems to be taking on the account-wide message abstraction now vs. keeping the current change narrow while preserving a migration path. Given the current set of use cases, the latter seems lower-risk, but I'd be happy to adjust if there are additional account-wide views already planned.

@cyj-git-0825

Copy link
Copy Markdown
Contributor Author

Could you please attach a screenshot for the new UI? Thanks!
Sureee~

held-tab-default scheduled-row-expanded-readonly scheduled-tab-list

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants