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 proposedre_content_items/re_content_translationsschema — reused verbatim, not redesigned),screen-blueprints.mdPrototype 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:publishcapabilities 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 types — blog, 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_translationsis created in the migration (matchingdata-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.mditself 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.
newsletterand any futureemail_campaigntype 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_campaigntypes. Indata-model.md's full 9-value enum but not part of the founder-confirmed 7 for this build; the DBtypecheck 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:
| Surface | Underlying store / function |
|---|---|
| List + editor seed | re_content_items rows for business_id (NEW query, replaces getSiteContent().draft.blog) |
| Save draft | INSERT/UPDATE re_content_items (NEW — service-role, sanitized same allowlist as today's sanitizeRichTextHtml/sanitizeBlog field limits, reused not reinvented) |
| Publish | UPDATE 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 check | lintFairHousing(bodyPlainText) (existing, reused as-is) |
| Auth + tenant scope | getRealtorAuthUser() + realtor_memberships, businessId = membership.account_id (existing pattern) |
Public surface per type (v1 decision — do not build 6 new route trees):
| Type | Public surface | Notes |
|---|---|---|
blog | /s/{slug}/blog index + /s/{slug}/blog/{postSlug} detail | Existing route, re-pointed to read re_content_items instead of metadata.website.blog[] |
buyer_guide, seller_guide, neighbourhood_page, market_update | Same /s/{slug}/blog index + detail, badged with a type chip | Reuses the one shipped long-form public template instead of 4 new route trees; explicitly NOT /s/{slug}/guides (the static catalog, see §0) |
social_post | No public page — copy-to-clipboard / per-platform text export only from the editor | A 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 |
newsletter | No 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
newsletteris 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.
| Bucket | Roles | Scope |
|---|---|---|
| Brokerage management | brokerage_owner, broker_admin | All posts, all types; create/edit/publish |
| Team management | team_admin | Team posts; create/edit/publish |
| Agent / ISA | agent, isa (granted content:item:create) | Own-authored; publish only if also granted content:item:publish |
| Marketing assistant | marketing_assistant | Per capability grant |
| Support / external | transaction_coordinator, external_partner | Fail-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
newsletterwithout 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_translationsexists 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 MCPapply_migrationonly, and run the backfill before pointing the public blog route at the new table (no window where/s/{slug}/blogreads 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_translationsis 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 andfair-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.