> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mavera.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Content Calendar → Generate Pipeline

### Scenario

Your marketing team maintains a Notion content calendar database with columns like Title, Status, Content Type, Target Persona, and Publish Date. Planned pieces sit in "Idea" or "Outlined" status with no draft. This job queries the database for upcoming content, then calls Mavera's Generate endpoint for each piece — producing first drafts at scale that match your existing brand voice.

**Flow:** Notion `POST /databases/{id}/query` (filter: status = "Planned") → For each row → Mavera `POST /api/v1/generations` (with brand\_voice\_id) → Draft content attached back as Notion page

### Code

<CodeGroup>
  ```python Python theme={"dark"}
  import os, requests, time

  NOTION = os.environ["NOTION_API_KEY"]
  MV = os.environ["MAVERA_API_KEY"]
  NB = "https://api.notion.com/v1"
  MB = "https://app.mavera.io/api/v1"
  NH = {
      "Authorization": f"Bearer {NOTION}",
      "Notion-Version": "2022-06-28",
      "Content-Type": "application/json",
  }
  MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}

  CALENDAR_DB_ID = "your-content-calendar-database-id"
  BRAND_VOICE_ID = "bv_your_voice_id"

  # 1. Query planned content from the calendar
  results = requests.post(f"{NB}/databases/{CALENDAR_DB_ID}/query", headers=NH, json={
      "filter": {
          "property": "Status",
          "select": {"equals": "Planned"},
      },
      "sorts": [{"property": "Publish Date", "direction": "ascending"}],
      "page_size": 20,
  }).json().get("results", [])

  print(f"Found {len(results)} planned content pieces")

  # 2. Generate a draft for each piece
  drafts = []
  for page in results:
      props = page.get("properties", {})
      title_parts = props.get("Title", {}).get("title", [])
      title = "".join(t.get("plain_text", "") for t in title_parts) or "Untitled"

      content_type = (props.get("Content Type", {}).get("select", {}) or {}).get("name", "blog post")
      persona = (props.get("Target Persona", {}).get("select", {}) or {}).get("name", "general audience")
      notes_parts = props.get("Notes", {}).get("rich_text", [])
      notes = "".join(t.get("plain_text", "") for t in notes_parts)

      gen = requests.post(f"{MB}/generations", headers=MH, json={
          "brand_voice_id": BRAND_VOICE_ID,
          "prompt": (
              f"Write a {content_type} titled '{title}' for {persona}. "
              f"{'Brief: ' + notes if notes else 'No additional brief.'} "
              f"Include an engaging intro, 3-5 sections with headers, and a conclusion with CTA. "
              f"Length: 800-1200 words."
          ),
      }).json()

      draft_text = gen.get("output", gen.get("content", ""))
      drafts.append({"title": title, "page_id": page["id"], "draft": draft_text})

      # 3. Append draft as a child block in the Notion page
      blocks = []
      for paragraph in draft_text.split("\n\n"):
          paragraph = paragraph.strip()
          if not paragraph:
              continue
          if paragraph.startswith("# "):
              blocks.append({"object": "block", "type": "heading_1",
                  "heading_1": {"rich_text": [{"type": "text", "text": {"content": paragraph[2:]}}]}})
          elif paragraph.startswith("## "):
              blocks.append({"object": "block", "type": "heading_2",
                  "heading_2": {"rich_text": [{"type": "text", "text": {"content": paragraph[3:]}}]}})
          else:
              for chunk in [paragraph[i:i+2000] for i in range(0, len(paragraph), 2000)]:
                  blocks.append({"object": "block", "type": "paragraph",
                      "paragraph": {"rich_text": [{"type": "text", "text": {"content": chunk}}]}})

      if blocks:
          requests.patch(f"{NB}/blocks/{page['id']}/children", headers=NH,
              json={"children": blocks[:100]})

      # 4. Update status to "Draft"
      requests.patch(f"{NB}/pages/{page['id']}", headers=NH, json={
          "properties": {"Status": {"select": {"name": "Draft"}}}
      })

      print(f"  ✓ {title} → {len(draft_text)} chars")
      time.sleep(0.5)

  print(f"\nGenerated {len(drafts)} drafts")
  ```

  ```javascript JavaScript theme={"dark"}
  const NOTION = process.env.NOTION_API_KEY;
  const MV = process.env.MAVERA_API_KEY;
  const NB = "https://api.notion.com/v1";
  const MB = "https://app.mavera.io/api/v1";
  const NH = {
    Authorization: `Bearer ${NOTION}`,
    "Notion-Version": "2022-06-28",
    "Content-Type": "application/json",
  };
  const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };

  const CALENDAR_DB_ID = "your-content-calendar-database-id";
  const BRAND_VOICE_ID = "bv_your_voice_id";

  // 1. Query planned content
  const results = await fetch(`${NB}/databases/${CALENDAR_DB_ID}/query`, {
    method: "POST", headers: NH,
    body: JSON.stringify({
      filter: { property: "Status", select: { equals: "Planned" } },
      sorts: [{ property: "Publish Date", direction: "ascending" }],
      page_size: 20,
    }),
  }).then(r => r.json()).then(d => d.results || []);

  console.log(`Found ${results.length} planned content pieces`);

  // 2. Generate drafts
  const drafts = [];
  for (const page of results) {
    const props = page.properties || {};
    const title = (props.Title?.title || []).map(t => t.plain_text).join("") || "Untitled";
    const contentType = props["Content Type"]?.select?.name || "blog post";
    const persona = props["Target Persona"]?.select?.name || "general audience";
    const notes = (props.Notes?.rich_text || []).map(t => t.plain_text).join("");

    const gen = await fetch(`${MB}/generations`, {
      method: "POST", headers: MH,
      body: JSON.stringify({
        brand_voice_id: BRAND_VOICE_ID,
        prompt: `Write a ${contentType} titled '${title}' for ${persona}. ` +
          `${notes ? "Brief: " + notes : "No additional brief."} ` +
          `Include intro, 3-5 sections with headers, conclusion with CTA. 800-1200 words.`,
      }),
    }).then(r => r.json());

    const draftText = gen.output || gen.content || "";
    drafts.push({ title, pageId: page.id, draft: draftText });

    // 3. Append draft blocks
    const blocks = [];
    for (const paragraph of draftText.split("\n\n")) {
      const p = paragraph.trim();
      if (!p) continue;
      if (p.startsWith("# ")) {
        blocks.push({ object: "block", type: "heading_1",
          heading_1: { rich_text: [{ type: "text", text: { content: p.slice(2) } }] } });
      } else if (p.startsWith("## ")) {
        blocks.push({ object: "block", type: "heading_2",
          heading_2: { rich_text: [{ type: "text", text: { content: p.slice(3) } }] } });
      } else {
        for (let i = 0; i < p.length; i += 2000) {
          blocks.push({ object: "block", type: "paragraph",
            paragraph: { rich_text: [{ type: "text", text: { content: p.slice(i, i + 2000) } }] } });
        }
      }
    }

    if (blocks.length) {
      await fetch(`${NB}/blocks/${page.id}/children`, {
        method: "PATCH", headers: NH,
        body: JSON.stringify({ children: blocks.slice(0, 100) }),
      });
    }

    // 4. Update status
    await fetch(`${NB}/pages/${page.id}`, {
      method: "PATCH", headers: NH,
      body: JSON.stringify({ properties: { Status: { select: { name: "Draft" } } } }),
    });

    console.log(`  ✓ ${title} → ${draftText.length} chars`);
    await new Promise(r => setTimeout(r, 500));
  }

  console.log(`\nGenerated ${drafts.length} drafts`);
  ```
</CodeGroup>

### Example Output

```text theme={"dark"}
Found 8 planned content pieces
  ✓ Q3 Product Launch Blog → 1847 chars
  ✓ Customer Success Webinar Script → 2103 chars
  ✓ SEO Guide: AI in Marketing → 1654 chars
  ✓ Case Study: Acme Corp → 1422 chars
  ✓ LinkedIn Carousel: 5 Tips → 892 chars
  ✓ Email Nurture: Onboarding Series #3 → 1238 chars
  ✓ Whitepaper: ROI Framework → 3201 chars
  ✓ Social Thread: Product Update → 756 chars

Generated 8 drafts
```

### Error Handling

<AccordionGroup>
  <Accordion title="Database not shared with integration">If the query returns `404` or `object not found`, open the database in Notion → ⋯ → Connections → add your integration. This is the most common issue.</Accordion>
  <Accordion title="Property name mismatch">Notion property names are case-sensitive. If `Status` is actually `status` or `Content Status` in your database, the filter returns zero results. Use `GET /databases/{id}` to inspect property names.</Accordion>
  <Accordion title="Rate limit (3 req/sec)">The loop includes a 500ms delay. For calendars with 50+ pieces, add exponential backoff on 429 or batch into runs of 10.</Accordion>
  <Accordion title="Block content limit">Notion limits `rich_text` content to 2000 characters per block. The code chunks long paragraphs. Append requests are limited to 100 blocks per call.</Accordion>
</AccordionGroup>
