# outwrite.ai: Lovable / AI agents blog contract

This public reference is for the agent implementing a customer's website. The user should only copy setup prompts, configure the endpoint and enter server secrets. Do not require them to download or attach source files. Read the code below directly and create the needed files with your project tools.

## Choose the host's native implementation

Use the existing project framework and database. For Supabase, the schema and receiver below are a complete reference. For another database, preserve the same transactional invariants; do not translate the transaction into independent unguarded inserts. Public blog pages should use the site's own server-rendered routes: /blog and /blog/{slug}. Inspect the installed router before choosing route filenames. Existing sites may keep their established paths and slugs. For a custom article path, configure the receiver URL template, for example OUTWRITE_BLOG_URL=https://example.com/articles/{slug}; adapt the native renderer to the same index and article routes. The optional rendering adapters expect a plain index URL, not a template.

New Lovable projects use server rendering; older Vite projects can serve an empty shell to ordinary requests. Verify actual HTML without JavaScript and a real 404 for unknown slugs. Do not claim that client-only routing satisfies this contract. Hosting may require the agent to add server rendering or a server route. Do not force every project onto a separate HTML Edge Function.

## Receiver settings and setup order

1. Build the pages and deploy a POST receiver named outwrite-webhook. It may return setup-incomplete until secrets are entered. Supabase requires verify_jwt=false for this function only because outwrite.ai does not have your project's JWT; X-Outwrite-Secret authentication remains mandatory.
2. Return the deployed HTTPS endpoint URL. The user saves it under outwrite.ai → Integrations → Webhook with Complete article v2 and authentication enabled.
3. The user copies the secret into OUTWRITE_WEBHOOK_SECRET using secure server settings. OUTWRITE_BRAND_ID is the workspace UUID copied from the same screen. OUTWRITE_BLOG_URL is the public blog index, for example https://example.com/blog. Never paste secrets into chat, frontend bundles, article records or logs.
4. The endpoint stays closed until settings are complete. Test connection sends is_test:true and is_draft:true; authenticated tests return success and write nothing. Then test a private draft and an explicitly authorized live article.

## Article and delivery invariants

- The complete v2 payload contains stable article id, brand_id, content_id, title, slug, HTML content, content_markdown, tags, metadescription, thumbnail, thumbnail_alt_text, featured_image_credit, keyword_seed, language_code, campaign_id/name, batch_id, publisher and optional author metadata, timestamps, operation_id, revision and is_draft. Preserve null optional fields without inventing metadata. See https://outwrite.ai/docs/integrations/webhook for the full JSON example and aliases.
- Only published records are publicly readable. Private revisions and receipts must not be exposed through public queries. Revoke anonymous/authenticated writes and service-only RPC execution. A draft update must preserve the currently published revision.
- Serialize concurrent deliveries for an article. Store a canonical request hash and immutable exact receipt by operation_id. Same operation and content returns that receipt; different content with the same operation ID fails. Older or conflicting revisions must not replace newer content. Keep the original public slug across updates. A slug collision must be resolved without overwriting another article.
- Hash exact stored title + one zero byte + exact stored HTML using UTF-8 SHA-256, lowercase hexadecimal. Sanitize HTML only at rendering time with a real HTML parser. Keep heading structure, safe links, tables, image alt text and photo attribution.
- Return only after the transaction commits. Receipt: operation_id, revision, content_hash, remote_id, status remote_draft or published. A published receipt also contains the absolute article URL. A plain {url} response is not sufficient for this protocol.
- Live initial HTML includes the exact article's title/body and <meta name="outwrite-article" content="ARTICLE_ID:REVISION:CONTENT_HASH">. Never add that marker to an index, generic shell, draft or missing article. The live article URL must answer directly without relying on redirects; old query-string receipt URLs must keep working when upgrading.
- Callback tokens in publication_receipt are operation-scoped and must stay private. The synchronous receiver below does not need to retain them. Do not log full delivery bodies.
- On an uncertain response, inspect the saved operation receipt before retrying. Do not turn errors into false success, bypass publication checks or publish drafts to make tests pass.

## Blog quality checklist

Match the website's branding and navigation. /blog lists published content with covers, descriptions, dates and pagination. Article pages have readable typography, responsive covers/tables, one main heading, appropriate contents navigation, tags, breadcrumbs, safe citations and optional real author details. Include unique title/description, canonical, Open Graph and social image metadata, accurate BlogPosting JSON-LD, and a sitemap containing only public URLs. Update caches when a live revision changes. Keep missing pages 404 and drafts private.

## Supabase reference (create these files with your tools)

The SQL below is for the customer's blog database, never outwrite.ai's application database. Inspect existing schema first and preserve unrelated tables. Use the dedicated outwrite_articles table names to avoid collisions. Dependencies are pinned in the entry point. Set function JWT verification only for outwrite-webhook, not globally.

```toml
[functions.outwrite-webhook]
verify_jwt = false
```

### schema.sql

```sql
-- outwrite.ai blog storage for a Supabase-backed site (Lovable or any AI agent).
-- Safe to apply again when upgrading: existing articles, slugs, private
-- revisions and stored delivery receipts are kept unchanged.
--
-- Receipt URLs: OUTWRITE_BLOG_URL is the public blog index, for example
-- https://example.com/blog. Live articles are confirmed at /blog/{slug}.
-- A site with different article routes may instead use a template containing
-- {slug} once, for example https://example.com/articles/{slug}.
begin;
create extension if not exists pgcrypto with schema extensions;
create schema if not exists outwrite_private;
revoke all on schema outwrite_private from public,anon,authenticated;
create table if not exists public.outwrite_articles (
 id uuid primary key, revision integer not null,
 slug text not null unique, title text not null, content text not null,
 content_markdown text not null, tags jsonb not null default '[]',
 metadescription text, thumbnail text, thumbnail_alt_text text,
 metadata jsonb not null default '{}', content_hash text not null,
 published_at timestamptz not null default now(), updated_at timestamptz not null default now()
);
create index if not exists outwrite_articles_published on public.outwrite_articles(published_at desc);
alter table public.outwrite_articles enable row level security;
revoke all on public.outwrite_articles from public,anon,authenticated;
grant select on public.outwrite_articles to anon,authenticated;
grant all on public.outwrite_articles to service_role;
drop policy if exists outwrite_published_read on public.outwrite_articles;
create policy outwrite_published_read on public.outwrite_articles for select to anon,authenticated using (true);
-- Only published revisions ever enter the public table. Drafts and receipts stay private.
create table if not exists outwrite_private.revisions (
 article_id uuid not null, revision integer not null, document jsonb not null,
 content_hash text not null, primary key(article_id,revision)
);
create table if not exists outwrite_private.deliveries (
 operation_id uuid primary key, request_hash text not null, receipt jsonb not null
);
alter table outwrite_private.revisions enable row level security;
alter table outwrite_private.deliveries enable row level security;
grant usage on schema outwrite_private to service_role;
grant all on all tables in schema outwrite_private to service_role;
create or replace function public.outwrite_receive(p_payload jsonb,p_blog_url text) returns jsonb
language plpgsql security invoker set search_path='' as $$
declare aid uuid:=(p_payload->>'id')::uuid; oid uuid:=(p_payload->>'operation_id')::uuid;
 rev integer:=(p_payload->>'revision')::integer; doc jsonb:=p_payload-'operation_id'-'is_draft';
 request_hash text; content_hash text; previous outwrite_private.deliveries;
 existing outwrite_private.revisions; receipt jsonb; stable_slug text; highest integer;
 blog_url text:=btrim(coalesce(p_blog_url,''));
begin
 if rev<1 or jsonb_typeof(p_payload->'is_draft')<>'boolean' or length(p_payload->>'title')=0 or length(p_payload->>'content')=0 then raise exception using errcode='22023',message='Invalid article';end if;
 perform pg_advisory_xact_lock(hashtextextended(aid::text,0));
 request_hash:=encode(extensions.digest(convert_to(p_payload::text,'UTF8'),'sha256'),'hex');
 -- An exact replay returns its original receipt unchanged, including receipts
 -- issued before this version (?slug= URLs, which the blog still serves).
 select * into previous from outwrite_private.deliveries where operation_id=oid;
 if found then
  if previous.request_hash<>request_hash then raise exception using errcode='22023',message='Operation ID was already used for different content';end if;
  return previous.receipt;
 end if;
 -- Reject a misconfigured public URL before writing anything.
 if blog_url !~ '^https?://[^/?#[:space:]]+' or blog_url ~ '[[:space:]]'
   or length(blog_url)-length(replace(blog_url,'{slug}',''))>6 then
  raise exception using errcode='22023',message='Receiver blog URL is invalid. Nothing changed.';
 end if;
 select max(revision) into highest from outwrite_private.revisions where article_id=aid;
 if highest>rev then raise exception using errcode='22023',message='A newer revision already exists. Nothing changed.';end if;
 content_hash:=encode(extensions.digest(convert_to(p_payload->>'title','UTF8')||'\x00'::bytea||convert_to(p_payload->>'content','UTF8'),'sha256'),'hex');
 select * into existing from outwrite_private.revisions where article_id=aid and revision=rev;
 if found and existing.content_hash<>content_hash then raise exception using errcode='22023',message='This revision already has different content. Nothing changed.';end if;
 if exists(select 1 from outwrite_private.revisions where article_id=aid and document->>'brand_id'<>p_payload->>'brand_id') then raise exception using errcode='22023',message='Brand mismatch';end if;

 insert into outwrite_private.revisions values(aid,rev,doc,content_hash) on conflict(article_id,revision) do update set document=excluded.document;
 -- A published article keeps its first slug, so its URL never changes.
 select slug into stable_slug from public.outwrite_articles where id=aid;
 if stable_slug is null then
  stable_slug:=trim(both '-' from left(trim(both '-' from regexp_replace(lower(coalesce(p_payload->>'slug','')),'[^a-z0-9]+','-','g')),200));
  if stable_slug='' then stable_slug:=aid::text;end if;
  -- Serialize competing new articles for the same slug before checking ownership.
  perform pg_advisory_xact_lock(hashtextextended('outwrite-slug:'||stable_slug,1));
  if exists(select 1 from public.outwrite_articles where slug=stable_slug and id<>aid) then stable_slug:=stable_slug||'-'||aid::text;end if;
 end if;
 if not (p_payload->>'is_draft')::boolean then
  insert into public.outwrite_articles(id,revision,slug,title,content,content_markdown,tags,metadescription,thumbnail,thumbnail_alt_text,metadata,content_hash)
  values(aid,rev,stable_slug,p_payload->>'title',p_payload->>'content',p_payload->>'content_markdown',p_payload->'tags',p_payload->>'metadescription',p_payload->>'thumbnail',p_payload->>'thumbnail_alt_text',jsonb_build_object('author',doc->'author','author_url',doc->'author_url','author_bio',doc->'author_bio','language_code',doc->'language_code','publisher_name',doc->'publisher_name','publisher_url',doc->'publisher_url','featured_image_credit',doc->'featured_image_credit'),content_hash)
  on conflict(id) do update set revision=excluded.revision,title=excluded.title,content=excluded.content,content_markdown=excluded.content_markdown,tags=excluded.tags,metadescription=excluded.metadescription,thumbnail=excluded.thumbnail,thumbnail_alt_text=excluded.thumbnail_alt_text,metadata=excluded.metadata,content_hash=excluded.content_hash,updated_at=now();
 end if;
 receipt:=jsonb_build_object('operation_id',oid,'revision',rev,'content_hash',content_hash,'remote_id',aid,'status',case when (p_payload->>'is_draft')::boolean then 'remote_draft' else 'published' end);
 if not (p_payload->>'is_draft')::boolean then
  receipt:=receipt||jsonb_build_object('url',case when strpos(blog_url,'{slug}')>0 then replace(blog_url,'{slug}',stable_slug) else rtrim(blog_url,'/')||'/'||stable_slug end);
 end if;
 insert into outwrite_private.deliveries values(oid,request_hash,receipt);
 return receipt;
end $$;
revoke all on function public.outwrite_receive(jsonb,text) from public,anon,authenticated;
grant execute on function public.outwrite_receive(jsonb,text) to service_role;
commit;
```

### receiver.mjs

```javascript
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const json = (body, status = 200) =>
  Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
async function equalSecret(a, b) {
  if (!a || !b) return false;
  const digest = async (s) =>
    new Uint8Array(
      await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)),
    );
  const [x, y] = await Promise.all([digest(a), digest(b)]);
  let difference = 0;
  for (let i = 0; i < x.length; i++) difference |= x[i] ^ y[i];
  return difference === 0;
}
export async function handleWebhook(req, { secret, brandId, ingest, blogUrl }) {
  if (req.method !== "POST") return json({ error: "POST required" }, 405);
  if (!secret || !uuid.test(brandId || "") || !blogUrl)
    return json({ error: "Receiver setup incomplete" }, 503);
  if (!(await equalSecret(req.headers.get("X-Outwrite-Secret"), secret)))
    return json({ error: "Unauthorized" }, 401);
  let raw = "";
  const reader = req.body?.getReader();
  if (!reader) return json({ error: "JSON required" }, 422);
  let size = 0;
  const decoder = new TextDecoder();
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    size += value.byteLength;
    if (size > 2000000) {
      await reader.cancel();
      return json({ error: "Payload too large" }, 422);
    }
    raw += decoder.decode(value, { stream: true });
  }
  raw += decoder.decode();
  let p;
  try {
    p = JSON.parse(raw);
  } catch {
    return json({ error: "Invalid JSON" }, 422);
  }
  if (!p || typeof p !== "object" || Array.isArray(p))
    return json({ error: "Article object required" }, 422);
  if (p.is_test === true && p.is_draft === true)
    return json({ ok: true, is_test: true, schema_version: 2 });
  if (
    p.schema_version !== 2 ||
    !uuid.test(p.id || "") ||
    !uuid.test(p.operation_id || "") ||
    p.brand_id !== brandId ||
    !Number.isSafeInteger(p.revision) ||
    p.revision < 1 ||
    p.revision > 2147483647 ||
    typeof p.is_draft !== "boolean" ||
    typeof p.title !== "string" ||
    !p.title.trim() ||
    p.title.length > 1000 ||
    typeof p.content !== "string" ||
    !p.content ||
    typeof p.content_markdown !== "string" ||
    typeof p.slug !== "string" ||
    p.slug.length > 200 ||
    !Array.isArray(p.tags) ||
    p.tags.length > 30 ||
    p.tags.some((t) => typeof t !== "string" || t.length > 500)
  )
    return json({ error: "Invalid article, revision or brand" }, 422);
  const allowed = [
    "id",
    "operation_id",
    "brand_id",
    "revision",
    "is_draft",
    "title",
    "content",
    "content_markdown",
    "slug",
    "tags",
    "metadescription",
    "thumbnail",
    "thumbnail_alt_text",
    "featured_image_credit",
    "keyword_seed",
    "language_code",
    "campaign_id",
    "campaign_name",
    "batch_id",
    "author",
    "author_url",
    "author_bio",
    "publisher_name",
    "publisher_url",
    "created_at",
    "updated_at",
  ];
  const payload = Object.fromEntries(
    allowed.filter((k) => p[k] !== undefined && (!["author", "author_url", "author_bio"].includes(k) || p[k] != null)).map((k) => [k, p[k]]),
  );
  for (const k of [
    "metadescription",
    "thumbnail",
    "thumbnail_alt_text",
    "keyword_seed",
    "language_code",
    "campaign_id",
    "campaign_name",
    "batch_id",
    "author",
    "author_url",
    "author_bio",
    "publisher_name",
    "publisher_url",
    "created_at",
    "updated_at",
  ])
    if (
      payload[k] != null &&
      (typeof payload[k] !== "string" || payload[k].length > 4000)
    )
      return json({ error: "Invalid metadata" }, 422);
  for (const k of ["thumbnail", "publisher_url", "author_url"])
    if (payload[k] != null) {
      try {
        const u = new URL(payload[k]);
        if (
          !["https:", "http:"].includes(u.protocol) ||
          u.username ||
          u.password
        )
          throw Error();
      } catch {
        payload[k] = null;
      }
    }
  try {
    return json(await ingest(payload, blogUrl));
  } catch (e) {
    return json(
      {
        error:
          e?.code === "22023"
            ? e?.message === "Receiver blog URL is invalid. Nothing changed."
              ? "Receiver blog URL is invalid. Check OUTWRITE_BLOG_URL. Nothing changed."
              : "Article conflicts with a saved revision. Nothing changed."
            : "Delivery could not be confirmed. Inspect the saved delivery before retrying.",
      },
      e?.code === "22023" ? 422 : 503,
    );
  }
}
```

### webhook-index.ts

```typescript
import { createClient } from "npm:@supabase/supabase-js@2.105.3";
import { handleWebhook } from "./receiver.mjs";
// Configure these through Supabase secrets, never a browser bundle or chat.
const db = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
  { auth: { persistSession: false } },
);
Deno.serve((req) =>
  handleWebhook(req, {
    secret: Deno.env.get("OUTWRITE_WEBHOOK_SECRET"),
    brandId: Deno.env.get("OUTWRITE_BRAND_ID"),
    blogUrl: Deno.env.get("OUTWRITE_BLOG_URL"),
    ingest: async (payload: unknown, blogUrl: string) => {
      const { data, error } = await db.rpc("outwrite_receive", {
        p_payload: payload,
        p_blog_url: blogUrl,
      });
      if (error) throw error;
      return data;
    },
  }),
);
```

## Optional rendering references

Read [render.mjs](https://outwrite.ai/integrations/lovable/render.mjs) for sanitizing and metadata, [server.mjs](https://outwrite.ai/integrations/lovable/server.mjs) for Node routing, or [blog-index.ts](https://outwrite.ai/integrations/lovable/blog-index.ts) for a Supabase function adapter. Prefer native application routes; Supabase's default function domain rewrites HTML responses to plain text, so it is not a public blog host without suitable domain/routing support. These are references for the agent, not files the user must transfer.

## Required acceptance

Test authentication, workspace isolation, no-op test requests, private drafts, authorized publication, duplicate/concurrent delivery, operation conflicts, old revisions, draft-after-live, stable slugs, null metadata, unsafe markup, missing routes, mobile layout and initial public HTML/marker. Inspect sitemap and page metadata. Use disposable fixtures; do not publish real customer articles during setup without permission.

References: [Lovable rendering](https://docs.lovable.dev/features/seo-aeo), [Supabase function configuration](https://supabase.com/docs/guides/functions/function-configuration), [Supabase HTML limits](https://supabase.com/docs/guides/functions/limits).
