Integrations
Blog-to-Social Converter
Ingest a blog post as a raw source, compile it into your knowledge base, and generate verified social content.
Prerequisites
- A Senso API key (create one on the API Keys page)
- A Firecrawl API key (get one at firecrawl.dev)
- Python 3.10+
export SENSO_API_KEY="YOUR_API_KEY"
export FIRECRAWL_KEY="YOUR_FIRECRAWL_KEY"
pip install requests firecrawl-py richHow it works
- Scrape — Firecrawl extracts Markdown from the blog post (your raw source)
- Ingest —
POST /org/kb/uploadto get a presigned S3 URL, PUT the raw source, then poll its KB node until compiled - Set up brand kit —
PUT /org/brand-kitto define your brand voice, persona, and writing rules. Theguidelinesobject accepts a fixed set of keys (any other key returns a 400):brand_name,brand_domain,brand_description,voice_and_tone,author_persona, andglobal_writing_rules. - Create content types —
POST /org/content-typesto define output templates (e.g. "Tweet Thread", "LinkedIn Post"). Theconfigobject accepts a fixed set of keys (any other key returns a 400):template,template_spec,cta_text,cta_destination, andwriting_rules. - Create a prompt —
POST /org/promptsto define the question driving generation. Prompts have atype:awareness,consideration,decision, orevaluation. - Generate —
POST /org/content-generation/samplewith thegeo_question_id(prompt) andcontent_type_id. The API returns asample_job_id; poll/org/content-generation/sample-jobs/{sample_job_id}until completion. The completed job 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. Set Content-Type to the SAME value declared above —
# the presigned URL signs it, so a mismatch is rejected by S3.
requests.put(result["upload_url"], data=file_bytes,
headers={"Content-Type": "text/markdown"})
print(f"Uploaded -> {content_id}")
# /org/kb/upload returns a CONTENT id, but the /org/kb/nodes/{id}/... endpoints
# need the NODE id — resolve it by matching the content id in the KB tree.
nodes = requests.get(f"{BASE}/org/kb/find", headers=HEADERS,
params={"q": filename}).json()["nodes"]
node_id = next(n["kb_node_id"] for n in nodes if n["content_id"] == content_id)
# Poll the KB node until ingestion finishes. (GET /org/content/{id} is for
# generated content; KB sources are read via /org/kb/nodes/{id}/content.)
while True:
node = requests.get(f"{BASE}/org/kb/nodes/{node_id}/content", headers=HEADERS).json()
if node.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.
# `guidelines` accepts only the keys below — any other key is
# rejected with a 400. The content engine uses them 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. `config` accepts only
# these keys (any other key is rejected with a 400): `template`,
# `template_spec`, `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 == 202:
job = resp.json()
while True:
status = requests.get(
f"{BASE}/org/content-generation/sample-jobs/{job['sample_job_id']}",
headers=HEADERS,
).json()
if status["status"] in {"completed", "failed", "expired"}:
break
time.sleep(2)
if status["status"] != "completed":
print(f" Error: {status.get('error', {}).get('message', status['status'])}")
continue
gen = status["result"]
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 object accepts only the keys shown above — any other key is rejected with a 400. (A PATCH must include at least one of them.)
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 object accepts only template, template_spec, cta_text, cta_destination, and writing_rules (an array of strings) — any other key is rejected with a 400.
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 a queued sample job:
| Field | Description |
|---|---|
sample_job_id | UUID of the async sample generation job |
status | queued, running, completed, failed, or expired |
GET /org/content-generation/sample-jobs/{sample_job_id}. Completed jobs include result:| Field | Description |
|---|---|
result.content_id | UUID of the generated content item |
result.version_id | UUID of this version |
result.version_num | Version number (starts at 1) |
result.raw_markdown | The generated content as Markdown |
result.seo_title | SEO-optimized title |
result.url_slug | URL-safe slug |
result.editorial_status | draft (ready for review) |
result.publish_status | skipped unless you publish during generation |
