Skip to main content

WiseAI Realtor — Content Studio (7-type AI content editor) Expected Output Spec

⛔ STATUS: DRAFT — NOT APPROVED. CLAUDE.md Rule #17 (HARD GATE) is NOT satisfied.

Stage-1 agent research. Do not build the customer-facing screen until the founder approves and confirms the open items in a Stage-2 interview.

Sourced from (read-only, 2026-07-01): data-model.md §1.10 (the exact proposed re_content_items / re_content_translations schema — reused verbatim, not redesigned), screen-blueprints.md Prototype 8 (the 3-pane layout + 7-type list this spec targets), site-content.ts (the blog storage being migrated OFF of), guides.ts (the EXISTING static 14-guide catalog — confirmed code-defined/version-controlled, NOT per-tenant editable, and therefore a DIFFERENT thing from this spec's agent-authored "buyer/seller guide" content type — see §0's explicit non-collision note), listing-kit.ts (the approved on-demand LLM pattern), fair-housing.ts (existing compliance linter), rbac.ts (content:item:create/content:item:publish capabilities already defined, unused today).


0. Scope — what this spec covers, and what it does NOT

This spec covers Content Studio: a workspace under /realtor/app/studio supporting 7 content typesblog, newsletter, social_post, buyer_guide, seller_guide, neighbourhood_page, market_update — with a list, a write/AI-generate editor, a compliance check, and publish.

Does NOT cover (explicitly deferred, not silently dropped):

  • Multilingual generation. re_content_translations is created in the migration (matching data-model.md §1.10 exactly, so no second migration is needed later) but no UI or generation logic reads/writes it in this build — the Translate action stays visible-but-disabled, "Coming soon." Reason: do-not-reinvent.md itself flags per-language content generation as unbuilt and requiring native-speaker review before any script ships (mirrors the Punjabi/Urdu voice gate) — not something to rush into this PR.
  • Any outbound send pipeline. newsletter and any future email_campaign type can be authored and marked "Ready"/"Published" in Content Studio, but Content Studio never sends anything — no Resend dispatch, no recipient list, no CASL consent check. This is a hard guardrail (§6, §9): the founder presses send personally for every outbound send in this portfolio (CLAUDE.md Hard Guardrails), and a newsletter content type is not an exception. A "ready" newsletter is exported (copy/HTML download) for the agent to paste into their own tool, or handed to the founder to route through the existing outreach-send process — never a "Send now" button here.
  • open_house_followup / email_campaign types. In data-model.md's full 9-value enum but not part of the founder-confirmed 7 for this build; the DB type check constraint includes all 9 for forward-compat (cheap, additive), but the UI type picker only offers the 7 confirmed types (the other 2 shown as disabled "Coming soon" chips, consistent with how the original blog-only draft of this spec treated the other 6).

Non-collision note — guides vs. guides.ts: the existing /s/[slug]/guides surface (guides.ts, 14 entries) is a code-defined, version-controlled, GEO-optimized static resource library, shared structure/FAQ/schema shipped with the codebase — it is NOT per-tenant content and is NOT touched by this spec. Content Studio's buyer_guide/seller_guide types are agent-authored, per-tenant, editable/AI-generated pieces ("Terry's guide to buying in Woodstock") — a different object, rendered on a different public surface (§1), never confused with or replacing the static catalog.

Build posture: Promote, don't rebuild. re_content_items / re_content_translations are data-model.md's own proposed schema — reused verbatim. The migration is additive (CREATE TABLE IF NOT EXISTS, service-role-grant-only, founder-gated per CLAUDE.md Rule #16/#18) and backfills existing blog data from metadata.website.blog[] (the only tenant with real posts today is the Beckett demo) so nothing is lost. The AI generation path reuses listing-kit.ts's established LLM pattern; the compliance check reuses fair-housing.ts as-is.


1. The screen this spec governs + what it reads/writes + public surfaces

Layout (per screen-blueprints.md Prototype 8): a post list (left: type filter chips, title, status, last-updated, "New" button) and a two-pane editor when a post is open — plain editor (center: title, excerpt, body, type-specific fields per §4) + AI panel (right: Generate, Rewrite selection, Compliance check; Translate visible-disabled).

Reads/writes:

SurfaceUnderlying store / function
List + editor seedre_content_items rows for business_id (NEW query, replaces getSiteContent().draft.blog)
Save draftINSERT/UPDATE re_content_items (NEW — service-role, sanitized same allowlist as today's sanitizeRichTextHtml/sanitizeBlog field limits, reused not reinvented)
PublishUPDATE re_content_items SET status='published', published_at=now() + revalidatePath the affected public route (§1 table below)
AI "Generate" / "Rewrite"/api/real-estate/content-items/generate (NEW — unified under the same root as the CRUD routes below, not this draft's originally-proposed /api/realtor/content-studio/generate) — Anthropic Messages REST, Haiku 4.5, mirroring listing-kit.ts; prompt shape varies by type (§4)
Compliance checklintFairHousing(bodyPlainText) (existing, reused as-is)
Auth + tenant scopegetRealtorAuthUser() + realtor_memberships, businessId = membership.account_id (existing pattern)

Public surface per type (v1 decision — do not build 6 new route trees):

TypePublic surfaceNotes
blog/s/{slug}/blog index + /s/{slug}/blog/{postSlug} detailExisting route, re-pointed to read re_content_items instead of metadata.website.blog[]
buyer_guide, seller_guide, neighbourhood_page, market_updateSame /s/{slug}/blog index + detail, badged with a type chipReuses the one shipped long-form public template instead of 4 new route trees; explicitly NOT /s/{slug}/guides (the static catalog, see §0)
social_postNo public page — copy-to-clipboard / per-platform text export only from the editorA caption isn't a page; publishing to social itself is the existing social-oauth.ts/social-publisher.ts pipeline (ShareWiseAI), out of scope to wire here
newsletterNo public page, no send — HTML/plain-text export only (§0)

Migration (backfill, one-time, part of this build): for every tenant with existing metadata.website.blog[] entries (today: only the Beckett demo), insert one re_content_items row per post (type='blog'), preserving title/slug/excerpt/body/publish-state/dates. metadata.website.blog[] is left in place (read-only, unused going forward) — no destructive delete.

Demo tenant: Terry & Sheri Real Estate (…0c01, slug terry-and-sheri).


2. The AI-Bridge / honesty anchor

  • AI drafts, the agent decides — never auto-published, always reviewed before save, regardless of type.
  • Compliance check is informative, not gating — same posture as the blog-only draft of this spec; applies to all 7 types (fair-housing language matters as much in a neighbourhood page as a blog post).
  • "Published" never means "sent." A published newsletter is a finished, exportable draft — the UI must not use language ("Sent", "Delivered") that implies dispatch happened. This is the single most important honesty rule in this spec given the CASL/outbound-send guardrail.
  • Honest content state — status always reflects the real DB row, never an optimistic client-only state.

3. Role-based visibility

Unchanged from the blog-only draft — content:item:create / content:item:publish (already defined in rbac.ts) gate every type identically; no per-type capability split in v1.

BucketRolesScope
Brokerage managementbrokerage_owner, broker_adminAll posts, all types; create/edit/publish
Team managementteam_adminTeam posts; create/edit/publish
Agent / ISAagent, isa (granted content:item:create)Own-authored; publish only if also granted content:item:publish
Marketing assistantmarketing_assistantPer capability grant
Support / externaltransaction_coordinator, external_partnerFail-closed RealtorStub

4. Expected outputs

4.1 — Post list (all types)

Should see: every re_content_items row for the tenant, type filter chips (All + one per confirmed type), status, last-updated, sorted newest-first; "New" opens the type picker with all 7 confirmed types enabled (the 2 data-model-only types shown disabled, per §0).

Should NOT see: a post silently missing because the list reads a different query than the editor seeds from; a fabricated count.

4.2 — Write / AI-generate (per type)

Should see: title/excerpt/body for every type; type-specific extras where they matter — social_post gets a platform selector (Instagram/Facebook/ LinkedIn caption variants, reusing the tone/audience shape already proven in listing-kit.ts's social outputs) instead of a full body/SEO field set; neighbourhood_page/market_update may optionally link a community/listing for context, mirroring the QR campaign's "connect content" step. "Generate" opens a brief form (topic, audience, tone, optional linked listing/community, target keyword) shaped per type and returns an editable draft into the same editor.

Should NOT see: a blog-shaped form forced onto a social post (over-long body field for a caption); an AI call that overwrites unsaved manual edits; Generate/Rewrite available to a role without content:item:create.

4.3 — Compliance check + publish (per type + public surface)

Should see: compliance check available on every type; Publish routes to the correct public surface per the §1 table (blog/guide/market-update types appear on /s/{slug}/blog; social_post/newsletter never get a public page — Publish for those just flips status to "Ready"/"Published" as an internal state, with an explicit UI label difference so it's never confused with "went live on the website").

Should NOT see: Publish blocked by an unresolved compliance flag; a newsletter or social post appearing to have "gone out" anywhere; a buyer/seller guide post appearing under /s/{slug}/guides (the static catalog) instead of /s/{slug}/blog.


5. Empty / loading / error states

Unchanged in kind from the blog-only draft (§5 there) — applies per-type: "no posts of this type yet" CTA, generate-failure preserves the brief form, compliance-check failure never claims "no issues found," publish failure preserves Draft status, fail-closed RealtorStub for missing capability, skeletons not spinners while loading.


6. Carried-forward constraints (consistent across the batch)

  • No realtor pricing shown in-product; gate by role/capability only.
  • Honest metrics/state only, including the "Published ≠ Sent" rule (§2) — this is the constraint most likely to be violated by a future agent "helpfully" adding a send button; call it out explicitly in code review.
  • Never wire a real send/dispatch path for newsletter without a separate, explicitly-approved spec covering CASL consent, recipient list sourcing, and founder-press-send — out of scope here, full stop.
  • Language never hardcoded; re_content_translations exists in schema only in this build (§0) — do not half-wire a translate button that writes nothing usable.
  • Server-side sanitize is the trust boundary for AI-generated content, identical to hand-typed, across all 7 types.
  • guides.ts's static catalog is untouched — Content Studio's guide types are a separate object rendered on a separate public path (§1); do not merge or redirect one into the other.

7. Accessibility (AODA → WCAG 2.1 AA)

Unchanged in kind from the blog-only draft (§7 there): real list/table semantics, keyboard-operable rich-text toolbar, labelled AI-panel fields with aria-busy during generation, non-color-only compliance flags, ≥4.5:1 contrast, reduced-motion honored.


8. Acceptance checklist (QA runs on the deployed URL)

Behavioural verification on wiseaiagency.com (real host) against the demo tenant (…0c01); never "build passes". Sample at ≥2 timepoints.

// Migration
test.fixme('existing Beckett demo blog posts appear unchanged in the new list after the re_content_items backfill', () => {});
test.fixme('/s/{slug}/blog and /s/{slug}/blog/{postSlug} render from re_content_items post-migration, byte-equivalent content to pre-migration', () => {});

// List + types
test.fixme('post list shows all 7 confirmed types via filter chips; the 2 unconfirmed types are absent from the type picker or shown disabled', () => {});
test.fixme('a published buyer_guide/seller_guide/neighbourhood_page/market_update appears on /s/{slug}/blog, NOT /s/{slug}/guides', () => {});

// AI generation (per type)
test.fixme('Generate returns a type-shaped draft (social_post gets platform captions, not a blog body field) from a real Anthropic call', () => {});
test.fixme('a failed generate call preserves the brief form and shows an honest inline error', () => {});

// Compliance + publish
test.fixme('compliance check surfaces real lintFairHousing flags on every type; publish is never blocked by an open flag', () => {});
test.fixme('publishing a newsletter or social_post never triggers any send/dispatch; UI never labels it "Sent"', () => {});
test.fixme('Publish failure leaves status as Draft, never optimistically Published', () => {});

// RBAC + states
test.fixme('a role without content:item:create sees the fail-closed RealtorStub, not the editor', () => {});
test.fixme('empty/loading/generate-error/publish-error states render per §5 for every type', () => {});

9. Guardrails for agents building against this spec

  • Rule #17 not satisfied — do not build until founder approval.
  • Migration is founder-gated, additive, IF NOT EXISTS, service-role-grant- only (Rule #16/#18) — apply via Supabase MCP apply_migration only, and run the backfill before pointing the public blog route at the new table (no window where /s/{slug}/blog reads an empty table).
  • guides.ts's static catalog stays untouched — do not add per-tenant writes there; do not route Content Studio's guide types through it.
  • Never build a send/dispatch path for newsletter/email_campaign in this PR — this is a hard stop per CLAUDE.md's outbound-send guardrail (founder presses send personally; no cron may ever trigger a campaign send). If a future PR wants real sending, it needs its OWN spec + explicit founder approval, not an incidental add to this one.
  • re_content_translations is schema-only in this build — do not wire a translate button that calls a real LLM without native-speaker review process being defined first (mirrors the Punjabi/Urdu voice gate).
  • Reuse listing-kit.ts's LLM path and fair-housing.ts's linter as-is — do not introduce a second calling convention or a second linter.
  • If code diverges, update the spec first (founder approval), then the code.

End of spec. STATUS: DRAFT — NOT APPROVED. Open items for Stage-2: (1) confirm the exact re_content_items CHECK constraint values (9 vs 7 — this spec proposes 9 for forward-compat with a 7-value UI); (2) confirm the backfill runs as part of the migration PR itself vs. a separate one-time script; (3) confirm social_post's platform-caption shape should reuse listing-kit.ts's existing Instagram/Facebook output format exactly or needs its own.