Skip to content

AI: Message Series planner (scheduled-post series) #12

Open

Description

@Adron

feature: "message_series" turns a brief into a sequence of short connected posts.

Behaviour

  • Requires roughly a sentence or two of draft (there is a 10-word gate; under it returns 422 invalid_input).
  • /suggest returns a message_series artifact — the planned posts, for review.
  • /generate then either:
    • creates a list of the planned rows (default), or
    • with scheduleImmediately: true, creates individual scheduled posts spaced a few minutes apart → {scheduledMessageIds, firstScheduledAt, lastScheduledAt}.
  • crossPost may be passed on /generate (crossPostToBluesky, selectedMastodonIds, crossPostToLinkedIn, selectedLinkedInTargets, crossPostToTwitter, linkedInLinkAsFirstComment). It is sanitized server-side against the caller's connected accounts, and the tightest selected service's character limit caps generation.

Acceptance criteria

  • Composer entry point appears once the draft clears the word gate.
  • Preview lists every planned post in order, editable before confirming.
  • Toggle for "schedule these posts" vs "save as a list" → maps to scheduleImmediately.
  • The composer's current cross-post selection is forwarded as crossPost.
  • After scheduleImmediately, the existing scheduled-posts view reflects the new posts.
  • The 10-word gate is enforced client-side so the call is not wasted against quota.

Activity

  1. added
    parityWeb/API feature-parity work
    P1Table stakes
    on Sep 15, 2026
  2. Adron commented on Sep 16, 2026

    @Adron
    MemberAuthor

    Live-verified corrections from the service layer (#9 / PR #137) — read before building this

    Five things contradict what the AI epic and these issues assume.

    1. quota is not the same object on both endpoints.
    GET /api/ai/status → {usedToday, dailyLimit, remaining}. POST /api/ai/suggest → {usedToday, dailyLimit} with no remaining (verified twice). So AiQuota.Remaining is int?, and a post-suggest quota chip cannot read .Remaining — bind RemainingOrComputed instead. #10's "remaining quota is visible wherever an AI action is offered" has to account for that.

    2. There is no provider picker to build.
    AI is Anthropic-only, app-wide; per-user provider keys were removed server-side 2026-09-05. The provider field on /generate is audit-ledger-only and does not select anything, and the openai/gemini entries in defaultModels are dormant and unreachable. Don't surface a provider choice.

    3. crossPost has six fields, not two — crossPostToBluesky, selectedMastodonIds, crossPostToLinkedIn, selectedLinkedInTargets, crossPostToTwitter, linkedInLinkAsFirstComment. Both crossPost and scheduleImmediately are message_series-only (relevant to #12).

    4. A sixth 422 code exists that the epic didn't list: refused. Treat it like invalid_ai_output. Modelled and marked retryable.

    5. Quota accounting is asymmetric, and it shapes the UI.
    Input-validation 422s cost nothing (usedToday stayed 0 across two), but any call that reaches the model costs a unit — including failures. So client-side pre-flight is load-bearing, not politeness: enforce the word caps, the 10-word series gate (#12/#13), a missing powered_document source ref (#15), and the non-persistable-artifact rule (#11) before calling. And no auto-retry — a retry burns another unit.

    Also: context.spacingMinutes is accepted but ignored (series rows always land 4 minutes apart), so it's deliberately not exposed.

    Note on the exception type

    The code is surfaced on a new AiApiException, not on InterlinedApiException — the latter is sealed and was being rewritten in #130. Folding them together is flagged as a follow-up in the code. Catch AiApiException in these UI issues.

    Unverified

    POST /api/ai/generate was never called (it persists content to a shared test account). Its envelope is contract-transcribed, flagged unverified in doc comments, and keeps the raw created element for read-after-write. Whichever of #12–#15 lands first should verify it against a real account and tighten the model.

  3. Adron commented on Sep 16, 2026

    @Adron
    MemberAuthor

    ⚠️ Correction to the quota rule I posted earlier on this issue

    My earlier comment said: "input-validation 422s cost nothing … but any call that reaches the model costs a unit." The first half is too optimistic, and #10/#14/#15's probing proved it (PRs #173/#174/#177).

    An unrecognized context.mode is not cheap-rejected — it reaches the model and bills a unit, returning 422 invalid_ai_output.

    So the accurate rule is narrower: a 422 is free only for validation the server actually performs. Anything it doesn't recognise as invalid gets forwarded to the model and charged. Observed on the real quota counter:

    call cost
    powered_template /suggest 1
    powered_document /suggest (mode: from_article) 1
    mode: "not_a_mode" 1 — unexpectedly billed
    4 reference/scheme rejection probes 0

    The rejections that were free came back with prose specific enough to show verbatim: "List not found.", "A list must be selected.", "Document not found.", "Only http(s) URLs are supported."

    Practical consequence for this issue: client-side pre-flight must validate against a closed set of known-good values, not merely catch obvious omissions. Sending an unknown enum value is not a free mistake. And still no auto-retry.

    Also useful here

    The powered_template artifact shape is now pinned (it was contract-transcribed only):

    kind: "list"
    { title, description,
      dsl: { name, description,
             fields: [{ key, type, label, required, displayOrder,
                        options?, defaultValue?,
                        visibility?{ condition{ field, operator, value } } }] },
      rows: [ { <fieldKey>: value } ] }     // flat maps; null is a legal cell value
    

    Four members were on record nowhere in the repo: the dsl wrapper's name/description, and per-field displayOrder/options/defaultValue/visibility. The model attaches rules the editor doesn't surface (e.g. visibility.condition), which makes preserving unparsed members across an edit load-bearing — a naive re-serialize would silently drop them.

    powered_document returns {kind, title, markdown, outline[], isPublic} and matched the transcribed model exactly.

    Still unverified for all AI issues

    POST /api/ai/generate has never been called (it persists to a shared account), so {listId}/{documentId} field names are unconfirmed — every confirm path does read-after-write plus an explicit "don't retry, you'd duplicate" when no id comes back. Also unobserved: subscriber:false, providers:[], either 429, and a real research_url fetch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Table stakesarea:aiArea: aiparityWeb/API feature-parity work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions