Scenario
Your company’s Notion knowledge base — product docs, style guides, internal memos, blog drafts — represents your authentic voice. Instead of manually defining brand guidelines, you extract them from how your team actually writes. This job pulls pages from a knowledge base database, aggregates the text, and creates a Mavera Brand Voice that captures your organization’s natural tone, vocabulary, and patterns. Flow: NotionPOST /databases/{id}/query (knowledge base) → GET /blocks/{page_id}/children per page → Aggregate text → Mavera POST /api/v1/brand-voices → Brand Voice profile
Code
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"}
KB_DB_ID = "your-knowledge-base-database-id"
CATEGORIES = ["Product Docs", "Blog Drafts", "Style Guide", "Internal Comms"]
# 1. Query knowledge base pages
all_pages = []
for category in CATEGORIES:
r = requests.post(f"{NB}/databases/{KB_DB_ID}/query", headers=NH, json={
"filter": {"property": "Category", "select": {"equals": category}},
"sorts": [{"property": "Last edited time", "direction": "descending"}],
"page_size": 10,
})
if r.status_code == 429:
time.sleep(1)
r = requests.post(f"{NB}/databases/{KB_DB_ID}/query", headers=NH, json={
"filter": {"property": "Category", "select": {"equals": category}},
"page_size": 10,
})
data = r.json()
all_pages.extend(data.get("results", []))
time.sleep(0.4)
print(f"Found {len(all_pages)} knowledge base pages across {len(CATEGORIES)} categories")
# 2. Extract text from pages
def get_blocks_text(block_id):
texts = []
cursor = None
while True:
params = {"page_size": 100}
if cursor:
params["start_cursor"] = cursor
r = requests.get(f"{NB}/blocks/{block_id}/children", headers=NH, params=params)
if r.status_code == 429:
time.sleep(1); continue
r.raise_for_status()
data = r.json()
for block in data.get("results", []):
btype = block.get("type", "")
rt = block.get(btype, {}).get("rich_text", [])
text = "".join(t.get("plain_text", "") for t in rt)
if text.strip():
texts.append(text)
cursor = data.get("next_cursor")
if not cursor:
break
time.sleep(0.4)
return "\n".join(texts)
samples = []
for page in all_pages[:25]:
props = page.get("properties", {})
title_parts = props.get("Name", props.get("Title", {})).get("title", [])
title = "".join(t.get("plain_text", "") for t in title_parts)
text = get_blocks_text(page["id"])
if len(text) > 100:
samples.append(f"=== {title} ===\n{text[:1500]}")
combined = "\n\n---\n\n".join(samples)
print(f"Collected {len(samples)} pages ({len(combined)} chars)")
# 3. Create Brand Voice
bv = requests.post(f"{MB}/brand-voices", headers=MH, json={
"name": "Notion Knowledge Base Voice",
"samples": [combined[:50000]],
"description": (
f"Extracted from {len(samples)} internal knowledge base pages "
f"across categories: {', '.join(CATEGORIES)}. "
"Captures organizational writing patterns, technical vocabulary, and tone."
),
}).json()
print(f"Brand Voice: {bv.get('id', 'error')}")
# 4. Wait and inspect
time.sleep(5)
detail = requests.get(f"{MB}/brand-voices/{bv['id']}", headers=MH).json()
print(f"Status: {detail.get('status', 'unknown')}")
if detail.get("traits"):
print(f"Traits: {detail['traits']}")
# 5. Test with a generation
from openai import OpenAI
mavera = OpenAI(api_key=MV, base_url=MB)
test = mavera.responses.create(
model="mavera-1",
input=[{"role": "user", "content": "Write a 100-word product update announcement for a new API versioning feature."}],
extra_body={"brand_voice_id": bv["id"]},
)
print(f"\nTest generation:\n{test.output[0].content[0].text}")
const OpenAI = require("openai").default;
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 KB_DB_ID = "your-knowledge-base-database-id";
const CATEGORIES = ["Product Docs", "Blog Drafts", "Style Guide", "Internal Comms"];
// 1. Query pages per category
const allPages = [];
for (const category of CATEGORIES) {
const res = await fetch(`${NB}/databases/${KB_DB_ID}/query`, {
method: "POST", headers: NH,
body: JSON.stringify({
filter: { property: "Category", select: { equals: category } },
sorts: [{ property: "Last edited time", direction: "descending" }],
page_size: 10,
}),
});
if (res.status === 429) await new Promise(r => setTimeout(r, 1000));
const data = await (res.status === 429
? fetch(`${NB}/databases/${KB_DB_ID}/query`, { method: "POST", headers: NH,
body: JSON.stringify({ filter: { property: "Category", select: { equals: category } }, page_size: 10 }),
})
: Promise.resolve(res)).then(r => r.json());
allPages.push(...(data.results || []));
await new Promise(r => setTimeout(r, 400));
}
console.log(`Found ${allPages.length} pages across ${CATEGORIES.length} categories`);
// 2. Extract text
async function getBlocksText(blockId) {
const texts = [];
let cursor = null;
do {
const params = new URLSearchParams({ page_size: "100" });
if (cursor) params.set("start_cursor", cursor);
const res = await fetch(`${NB}/blocks/${blockId}/children?${params}`, { headers: NH });
if (res.status === 429) { await new Promise(r => setTimeout(r, 1000)); continue; }
const data = await res.json();
for (const block of data.results || []) {
const text = (block[block.type]?.rich_text || []).map(t => t.plain_text).join("");
if (text.trim()) texts.push(text);
}
cursor = data.next_cursor;
await new Promise(r => setTimeout(r, 400));
} while (cursor);
return texts.join("\n");
}
const samples = [];
for (const page of allPages.slice(0, 25)) {
const props = page.properties || {};
const titleProp = props.Name || props.Title || {};
const title = (titleProp.title || []).map(t => t.plain_text).join("");
const text = await getBlocksText(page.id);
if (text.length > 100) samples.push(`=== ${title} ===\n${text.slice(0, 1500)}`);
}
const combined = samples.join("\n\n---\n\n");
console.log(`Collected ${samples.length} pages (${combined.length} chars)`);
// 3. Brand Voice
const bv = await fetch(`${MB}/brand-voices`, { method: "POST", headers: MH,
body: JSON.stringify({
name: "Notion Knowledge Base Voice",
samples: [combined.slice(0, 50000)],
description: `From ${samples.length} pages across: ${CATEGORIES.join(", ")}.`,
}),
}).then(r => r.json());
console.log(`Brand Voice: ${bv.id}`);
// 4. Test
await new Promise(r => setTimeout(r, 5000));
const mavera = new OpenAI({ apiKey: MV, baseURL: MB });
const test = await mavera.responses.create({
model: "mavera-1",
input: [{ role: "user", content: "Write a 100-word product update for a new API versioning feature." }],
extra_body: { brand_voice_id: bv.id },
});
console.log(`\nTest:\n${test.output[0].content[0].text}`);
Example Output
Found 32 knowledge base pages across 4 categories
Collected 25 pages (28734 chars)
Brand Voice: bv_notion_kb_4x7m (status: ready)
Traits: Direct, technically precise, confident without jargon. Uses
short sentences. Favors "you" over "the user". Active voice.
Contractions in informal content, formal in docs.
Test generation:
We shipped API versioning today. Every endpoint now accepts a version
header — v1 stays stable while v2 rolls out. Your existing integrations
won't break. Migration is opt-in: add `X-API-Version: 2` when you're
ready. The big changes: nested filters, cursor pagination by default,
and typed error responses. We wrote a migration guide (3-minute read)
and updated every SDK. Questions? Hit us in #api-support.
Error Handling
Category property missing
Category property missing
If your KB database doesn’t have a
Category select property, query without the filter and use page_size: 30 to get a broad sample across all page types.Insufficient sample size
Insufficient sample size
Brand Voice extraction works best with 5,000+ words across varied content types. If you have fewer than 10 pages, supplement with published blog URLs via the
urls field.Rich text formatting lost
Rich text formatting lost
The extraction captures plain text only. Bold, italic, and code formatting are stripped. This is intentional — Brand Voice focuses on language patterns, not formatting.