Senso
Sign in

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

  • 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 rich

    How it works

    1. Scrape — Firecrawl extracts Markdown from the blog post (your raw source) 2. IngestPOST /org/kb/upload to get a presigned S3 URL, PUT the raw source, poll until compiled 3. Set up brand kitPUT /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 typesPOST /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 promptPOST /org/prompts to define the question driving generation. Prompts have a type: awareness, consideration, decision, or evaluation. 6. GeneratePOST /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-article

    Output:

    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:

    FieldDescription
    content_idUUID of the generated content item
    version_idUUID of this version
    version_numVersion number (starts at 1)
    raw_markdownThe generated content as Markdown
    seo_titleSEO-optimized title
    url_slugURL-safe slug
    editorial_statusdraft (ready for review)
    publish_statusunpublished until you publish it