Blog-to-Social Converter
Ingest a blog post as a raw source, compile it into your knowledge base, and generate verified social content.
Blog-to-Social Converter
Scrape a blog post, ingest it as a raw source into your knowledge base, define content types (your output templates), then generate verified content for each one. The output is grounded in your ingested source and comes back as Markdown ready for publishing.
Prerequisites
export SENSO_API_KEY="YOUR_API_KEY"
export FIRECRAWL_KEY="YOUR_FIRECRAWL_KEY"
pip install requests firecrawl-py richHow it works
1. Scrape — Firecrawl extracts Markdown from the blog post (your raw source)
2. Ingest — POST /org/kb/upload to get a presigned S3 URL, PUT the raw source, poll until compiled
3. Set up brand kit — PUT /org/brand-kit to define your brand voice, persona, and writing rules. The guidelines field is free-form JSON — canonical fields are brand_name, brand_domain, brand_description, voice_and_tone, author_persona, and global_writing_rules.
4. Create content types — POST /org/content-types to define output templates (e.g. "Tweet Thread", "LinkedIn Post"). The config field is free-form JSON — canonical fields are template, cta_text, cta_destination, and writing_rules.
5. Create a prompt — POST /org/prompts to define the question driving generation. Prompts have a type: awareness, consideration, decision, or evaluation.
6. Generate — POST /org/content-generation/sample with the geo_question_id (prompt) and content_type_id. The engine queries your compiled knowledge base and returns verified Markdown, SEO title, URL slug, and editorial metadata.
Full script
import hashlib, os, sys, time, requests
from firecrawl import FirecrawlApp
SENSO_API_KEY = os.environ["SENSO_API_KEY"]
FIRECRAWL_KEY = os.environ["FIRECRAWL_KEY"]
BASE = "https://apiv2.senso.ai/api/v1"
HEADERS = {"X-API-Key": SENSO_API_KEY, "Content-Type": "application/json"}
blog_url = sys.argv[1] if len(sys.argv) > 1 else "https://your-blog.com/great-article"
# ── 1. Scrape the blog post ──────────────────────────────────
print(f"Scraping {blog_url}...")
fc = FirecrawlApp(api_key=FIRECRAWL_KEY)
page = fc.scrape_url(blog_url, params={"formats": ["markdown"]})
markdown = page.get("markdown", "")
title = page.get("metadata", {}).get("title", "Blog Post")
print(f"Got: {title} ({len(markdown)} chars)")
# ── 2. Ingest into Senso ─────────────────────────────────────
file_bytes = markdown.encode("utf-8")
filename = f"{title[:50].replace(' ', '-').lower()}.md"
resp = requests.post(f"{BASE}/org/kb/upload", headers=HEADERS, json={
"files": [{
"filename": filename,
"file_size_bytes": len(file_bytes),
"content_type": "text/markdown",
"content_hash_md5": hashlib.md5(file_bytes).hexdigest(),
}]
})
result = resp.json()["results"][0]
content_id = result["content_id"]
# Upload to S3
requests.put(result["upload_url"], data=file_bytes)
print(f"Uploaded -> {content_id}")
# Poll until processed
while True:
item = requests.get(
f"{BASE}/org/content/{content_id}",
headers={"X-API-Key": SENSO_API_KEY},
).json()
if item.get("processing_status") == "complete":
break
time.sleep(1)
print("Processing complete.")
# ── 3. Set up brand kit ──────────────────────────────────────
# The brand kit defines your org's voice and writing rules.
# The guidelines field is free-form JSON — these are the
# canonical fields the content engine uses during generation.
requests.put(f"{BASE}/org/brand-kit", headers=HEADERS, json={
"guidelines": {
"brand_name": "Acme Corp",
"brand_domain": "acme.com",
"brand_description": "Developer tools for API infrastructure",
"voice_and_tone": "Clear, direct, technically confident. Avoid jargon when a simpler word works.",
"author_persona": "A senior engineer who has shipped at scale",
"global_writing_rules": [
"Use active voice",
"Lead with the insight, not the setup",
"No em-dashes or semicolons",
],
}
})
print("Brand kit saved.")
# ── 4. Create content types ──────────────────────────────────
# Content types are reusable output templates. The config field
# is free-form JSON — canonical fields are template, cta_text,
# cta_destination, and writing_rules.
content_types = [
{
"name": "Tweet Thread",
"config": {
"template": "A thread of 3-5 tweets. First tweet hooks the reader. Last tweet has the CTA. Each tweet ≤280 chars.",
"cta_text": "Read the full post",
"cta_destination": blog_url,
"writing_rules": [
"One idea per tweet",
"Use line breaks for readability",
"No hashtags in the thread body",
],
},
},
{
"name": "LinkedIn Post",
"config": {
"template": "A single LinkedIn post under 1300 characters. Open with a bold statement. End with a question to drive comments.",
"cta_text": "Link in comments",
"cta_destination": blog_url,
"writing_rules": [
"Professional but not stiff",
"Use short paragraphs (1-2 sentences)",
"Include one concrete metric or example",
],
},
},
]
type_ids = {}
for ct in content_types:
resp = requests.post(f"{BASE}/org/content-types", headers=HEADERS, json=ct)
if resp.status_code == 201:
data = resp.json()
type_ids[ct["name"]] = data["content_type_id"]
print(f"Created content type: {ct['name']} -> {data['content_type_id']}")
elif resp.status_code == 409:
# Already exists — list and find it
existing = requests.get(f"{BASE}/org/content-types", headers=HEADERS).json()
for t in existing.get("content_types", []):
if t["name"] == ct["name"]:
type_ids[ct["name"]] = t["content_type_id"]
print(f"Content type exists: {ct['name']} -> {t['content_type_id']}")
break
# ── 5. Create a prompt ────────────────────────────────────────
# A prompt is the question that drives generation.
# Types: awareness, consideration, decision, evaluation
resp = requests.post(f"{BASE}/org/prompts", headers=HEADERS, json={
"question_text": f"What are the key takeaways from: {title}?",
"type": "awareness",
})
prompt = resp.json()
prompt_id = prompt["prompt_id"]
print(f"Created prompt: {prompt_id}")
# ── 6. Generate content for each type ─────────────────────────
for name, type_id in type_ids.items():
print(f"\nGenerating {name}...")
resp = requests.post(
f"{BASE}/org/content-generation/sample",
headers=HEADERS,
json={
"geo_question_id": prompt_id,
"content_type_id": type_id,
},
)
if resp.status_code == 201:
gen = resp.json()
print(f" Content ID: {gen['content_id']}")
print(f" SEO Title: {gen['seo_title']}")
print(f" URL Slug: {gen['url_slug']}")
print(f" Status: {gen['editorial_status']}")
print(f" ---")
# Print first 300 chars of the generated markdown
print(f" {gen['raw_markdown'][:300]}...")
else:
print(f" Error {resp.status_code}: {resp.text}")Run it
python blog_to_social.py https://your-blog.com/great-articleOutput:
Scraping https://your-blog.com/great-article...
Got: 10 Lessons from Scaling Our API (3842 chars)
Uploaded -> a1b2c3d4-...
Processing complete.
Brand kit saved.
Created content type: Tweet Thread -> e5f6a7b8-...
Created content type: LinkedIn Post -> c9d0e1f2-...
Created prompt: d3e4f5a6-...Generating Tweet Thread...
Content ID: f7a8b9c0-...
SEO Title: 10 Lessons from Scaling Our API - Thread
URL Slug: 10-lessons-scaling-api-thread
Status: draft
---
1/ We scaled our API from 100 to 10,000 requests/sec last year.
Here are 10 hard-won lessons...
Generating LinkedIn Post...
Content ID: a0b1c2d3-...
SEO Title: What We Learned Scaling to 10K RPS
URL Slug: learned-scaling-10k-rps
Status: draft
---
After a year of scaling our API infrastructure, here are the
insights that actually mattered...
Key API details
Brand kit — org-wide voice and writing rules:
PUT /org/brand-kit
{
"guidelines": {
"brand_name": "Acme Corp",
"brand_domain": "acme.com",
"brand_description": "Developer tools for API infrastructure",
"voice_and_tone": "Clear, direct, technically confident.",
"author_persona": "A senior engineer who has shipped at scale",
"global_writing_rules": ["Use active voice", "Lead with the insight"]
}
}Returns brand_kit_id (UUID), org_id, guidelines, created_at, updated_at. The guidelines field is free-form JSON — the fields above are canonical and used by the content engine during generation.
Content types — reusable output templates:
POST /org/content-types
{
"name": "Tweet Thread",
"config": {
"template": "A thread of 3-5 tweets...",
"cta_text": "Read the full post",
"cta_destination": "https://your-blog.com/article",
"writing_rules": ["One idea per tweet"]
}
}Returns content_type_id (UUID), name, config, created_at, updated_at. The config field is free-form JSON — the fields above are canonical and used by the content engine during generation.
Prompts — the questions that drive generation:
POST /org/prompts
{
"question_text": "What are the key takeaways?",
"type": "awareness"
}Returns prompt_id (UUID), text, type. Types: awareness, consideration, decision, evaluation.
Content generation sample — generate for one prompt + content type:
POST /org/content-generation/sample
{
"geo_question_id": "uuid",
"content_type_id": "uuid"
}Returns:
| Field | Description |
|---|---|
content_id | UUID of the generated content item |
version_id | UUID of this version |
version_num | Version number (starts at 1) |
raw_markdown | The generated content as Markdown |
seo_title | SEO-optimized title |
url_slug | URL-safe slug |
editorial_status | draft (ready for review) |
publish_status | unpublished until you publish it |
