Automation · Sales and Marketing · Creative and Design

A daily post pick from your team's real wins

Each morning, scan Slack, call notes and merged PRs for one verifiable story worth posting. Get X and LinkedIn copy plus branded graphics in a Slack DM, or a clear "no post today". Nothing is published for you.

Created by Sam Claassen from type.com

What it does

  • Turns real wins from Slack, calls and merged PRs into a ready-to-post X and LinkedIn package.
  • Holds an 8/10 bar with safety checks, so a skipped day beats a mediocre post.
  • Renders an X image and an animated LinkedIn video in your brand colors.
  • Checks every number against its source and lists whose OK is needed before posting.

Before you start

  • Slack connected through the Type Slack app.
  • X and GitHub connected; Granola is optional.
  • A short positioning document in Type.

See an example

Example output from a sample run. Unless the example says otherwise, company names and figures are sample data.

Example Slack DM on a post day (illustrative, not real data)

Daily post pick — Agent fixed a billing bug before standup 9/10 · fits the "hand it off and it gets done" story · verified against PR #812 and the #eng thread

X post

At 6:41am an automation noticed failed invoice retries.

By 7:30 it had traced the cause, written a fix and opened a PR. CI passed at 7:52.

It could only touch the retry job. Nothing else.

The team reviewed it over coffee.

Optional first reply: "Who approved it? A human merged it after review."

LinkedIn post · ready to paste (→ steps, payoff line)

Before posting: confirm the engineer who owns the job is OK with the mention. Timing: X 9–11am, LinkedIn 9–10am, an hour apart. [x.png] [linkedin.mp4]

Example on a quiet day

Daily post pick — no post today No post today. Closest: new dashboard filters (6/10, a feature announcement, not surprising).

How to use A daily post pick from your team's real wins

  1. Select Use automation to open a new conversation with the automation attached.
  2. Choose a space and describe any changes you want before sending the message.
  3. Work with the agent to connect your integrations and review the automation before turning it on.
Browse the automation instructions
---
name: "Daily Social Post Radar"
description: "Every morning, look for one genuinely interesting, verifiable company story to post on X and LinkedIn, and DM the reviewer either a ready-to-post package with branded graphics or \"No post today\" with the closest near-miss. Never publishes."
connection_needs:
  - "Slack (the Type Slack app): read history and threads in the source channels, send a DM to the reviewer, open the DM, and post files into it."
  - "X / Twitter: search recent posts from the company account to avoid repeating a story. Read-only."
  - "GitHub (through the gh CLI in the sandbox): list and view merged pull requests in the company repository. Read-only."
  - "Optional: Granola call notes (query_granola_meetings) for quotable call moments."
  - "Type: read the positioning document, upload files, view attachments, and run the bundled skill's renderer in the managed sandbox."
inputs:
  - "destination_space: the Space for the automation and its bundled skill"
  - "company_name: the company or product name exactly as it should appear in posts and graphics"
  - "brand_name_rule: the naming rule for posts, for example: Always write acme.dev in lowercase, never bare \"Acme\"."
  - "company_x_handle: the company X account, for example @acme"
  - "reviewer_name: the person who reviews and posts"
  - "reviewer_slack_user_id: that person's Slack user ID"
  - "slack_connection: the Slack connection"
  - "source_slack_channels: the channels to scan, each as name, ID and what to look for (wins, general news, go-to-market chatter, engineering, marketing)"
  - "positioning_document: Type document with the current positioning (buyer, category, story, proof points, what not to lead with)"
  - "positioning_snapshot: a five-line summary of that document, used only when it cannot be read"
  - "positioning_channel_name: the Slack channel where positioning changes are discussed"
  - "positioning_channel_id: that channel's ID"
  - "github_repository: owner/repo whose merged pull requests are scanned"
  - "agent_pr_author: optional GitHub login of an automation or bot whose merged PRs count as autonomous work (for example app/your-bot)"
  - "granola_connection: optional Granola connection for call notes"
  - "x_connection: the X / Twitter connection used for dedupe"
  - "audience_timezone: time zone for suggested posting times (source default: America/Los_Angeles)"
  - "schedule_timezone: time zone for the daily run (source default: America/Los_Angeles, 07:00)"
  - "brand_background_gradient: CSS gradient behind the graphic card (source used a light pink-to-cream sunrise gradient)"
  - "brand_accent_color: CSS color for dots, time labels and the counter"
  - "brand_logo_svg_path: optional single-path SVG logo data; leave empty to show company_name as a wordmark"
  - "brand_logo_svg_viewbox: viewBox for the SVG logo, for example 0 0 112 35"
---

# Daily Social Post Radar

## Purpose

Most teams have one great story a week buried in Slack, call notes and merged pull requests, and nobody turns it into a post. Every morning this automation scans the last day or two, scores candidates against the company's current positioning, and either:

- prepares **one** ready-to-post package: an X post with an optional first reply, a LinkedIn post, an X still image and a LinkedIn animated video in the brand style, sources, and a "before posting" checklist; or
- says **"No post today"** with the closest near-miss in one line.

It never publishes to X or LinkedIn and never posts in a public Slack channel. The reviewer gets every result as a private Slack DM and posts it themselves. A skipped day is a good outcome; the bar is 8/10 with safety scored 2.

## Setup

Settle every binding before creating the automation. None inherits a value from the original workspace.

| Binding | Meaning and how to supply it |
| --- | --- |
| `{{input:destination_space}}` | The Space that holds the automation and the bundled skill. It needs the Slack, X, GitHub and (optional) Granola connections. |
| `{{input:company_name}}` | Name as written in posts, the graphic header and the wordmark. |
| `{{input:brand_name_rule}}` | One-line naming rule the copy must follow. |
| `{{input:company_x_handle}}` | Company X account, used to avoid re-posting a story. |
| `{{input:reviewer_name}}`, `{{input:reviewer_slack_user_id}}` | The person who receives the DM (find the ID with `slack_search_users`). |
| `{{input:slack_connection}}`, `{{input:x_connection}}`, `{{input:granola_connection}}` | Connections saved in `integrationAccounts`; never put connection IDs in instructions. Granola is optional: without it, skip the call-notes source. |
| `{{input:source_slack_channels}}` | A list such as `- #wins (C…): new-customer write-ups` resolved with `slack_search_channels`. The Type Slack app must be in each channel. |
| `{{input:positioning_document}}` | Type document ID of the positioning doc. If none exists, create one with the starter structure below before enabling. |
| `{{input:positioning_snapshot}}` | Five bullets (category, buyer, story, proof points, don't lead with) copied from that document. |
| `{{input:positioning_channel_name}}`, `{{input:positioning_channel_id}}` | Channel skimmed for the last 7 days of positioning changes. |
| `{{input:github_repository}}`, `{{input:agent_pr_author}}` | Repository scanned for notable merged PRs; the optional author marks autonomous agent work. The Space's GitHub connection must be able to read it. |
| `{{input:audience_timezone}}`, `{{input:schedule_timezone}}` | Posting-time suggestions and run time. The source ran daily at 07:00 America/Los_Angeles. |
| `{{input:brand_background_gradient}}`, `{{input:brand_accent_color}}`, `{{input:brand_logo_svg_path}}`, `{{input:brand_logo_svg_viewbox}}` | Brand values written into the bundled skill's `brand.json`. Leave the logo path empty to use a text wordmark. |

**Starter structure for the positioning document** (when the workspace has none):

```markdown
# Why <product>? (positioning)
## Buyer
## Category and real alternatives
## The short version (one-sentence story)
## Proof points
## What we're not leading with
```

**Create the bundled skill first.** Fill every placeholder in the five files under Reference implementation, then call `create_space_skill` with name `Daily Social Post Radar`, the description from the SKILL.md frontmatter, and the SKILL.md content. Add the other files with `add_skill_file`: `SPEC_FORMAT.md`, `example_spec.json` and `brand.json` as `reference`, and `render_post_graphics.py` as `script` with `scriptEntrypoint: true` and `scriptLanguage: "python"`. Then check the renderer: `materialize_skill`, run `python3 render_post_graphics.py example_spec.json /tmp/radar-check` from the returned directory (install `playwright` first if it is missing), and confirm it prints `"ok": true` with three files.

**Then create the automation** with `create_task`: the instructions under Reference implementation, `scheduleConfig: { mode: "specific_time", time: "07:00", days: all seven, timezone: {{input:schedule_timezone}} }`, `integrationAccounts` for Slack, X, GitHub and Granola, and `skillReferences` for the new skill. It is agent-only (`agentModeReason`: finding, scoring and writing a story is editorial judgment; the renderer is a skill script the agent runs in the sandbox). Create it paused and enable it after review.

## Triggers and execution

Instruction (agent-only) automation, scheduled every day at 07:00 in `{{input:schedule_timezone}}`. No model or run settings are configured in the source (agent defaults). It loads the bundled `daily-social-post-radar` skill and runs its renderer in the managed sandbox. No code stage.

## Workflow

1. **Window.** Everything since the previous run, about the last 24–48 hours.
2. **Positioning.** Read the positioning document fresh, and skim the positioning channel for the last 7 days. Fall back to the snapshot in the skill only when the document cannot be read.
3. **Gather candidates.** Slack history and promising threads in the source channels; Granola moments that are surprising, quotable or concrete (keep citation links); merged PRs in the repository by the agent author or notable launches.
4. **Dedupe.** The last ~20 posts on the company X account and earlier runs of this automation in the Space (`search_messages`). Never re-pitch a story; before re-pitching a near-miss PR, check its current state.
5. **Score** each candidate 0–2 on positioning fit, concrete and verifiable, surprising, safe to share, and fresh. **Post only at ≥8/10 with no zeros and safety = 2.** Otherwise the result is "No post today. Closest: <one line> (<score>/10, why it fell short)."
6. **Verify** every number and time against its primary source, use exact supported wording, and list unconfirmed facts and whose OK is needed.
7. **Write** the X post (4–6 short plain paragraphs: what happened, the numbers, one line of reassurance, a short closer; no hashtags, emojis, links or marketing voice) plus an optional first reply answering the obvious skeptical question, and the LinkedIn post (→ bullet steps, reassurance, business payoff). Follow the naming rule. Never put the post copy in the image.
8. **Render** a spec with `render_post_graphics.py` → `x.png` (3200×1800), `linkedin.mp4` (1080×1350, ~4s hold at the end) and `linkedin.png`; fix any `ok:false` error by shortening text. **Visual QA** each image with `upload_file` (draft) and `view_attachment`.
9. **Deliver** by private Slack DM, every run, with the bold title line; on post days attach `x.png` and `linkedin.mp4` as separate `slack_post_message` calls with `file_id` in the same DM. Suggested timing: X 9–11am, LinkedIn 9–10am on weekdays, an hour or two apart.

## State and failure behavior

- No stored state. Dedupe uses the company's recent X posts and earlier runs of this automation in the Space.
- Every run sends exactly one text DM (plus the two file messages on post days). Each message is sent once; when a send result is uncertain, the DM history is checked before retrying.
- If a Slack file upload fails, the DM says so and points to the Type run, where all three files are attached.
- If the positioning document is unreachable, the skill's snapshot is used.
- If the renderer returns `ok:false`, the text is shortened and the render repeated; it never falls back to hand-made graphics.
- Nothing is ever published to X or LinkedIn, and nothing is posted in a public Slack channel.

## Integration data and capabilities

| Integration | Data and capabilities used | Relevant semantics |
| --- | --- | --- |
| Slack (Type Slack app) | `slack_get_channel_history` (`channel_id`, `oldest`), `slack_get_thread_replies` (`channel_id`, `thread_ts`), `slack_get_message_permalink` (`channel_id`, `timestamp`), `slack_send_dm` (`user_id`), `slack_open_dm`, `slack_post_message` with `file_id`. | The app must be a member of each source channel. The DM channel ID comes from the `slack_send_dm` result. |
| X / Twitter | `twitter_search_recent_posts` with `from:<handle>` for dedupe. | Read-only. |
| GitHub | `gh pr list --state merged --search "merged:>=YYYY-MM-DD" --json number,title,author,mergedAt` and `gh pr view <n> --json state,mergedAt`, with `GH_TOKEN` from the `tokenEnv` in `TYPE_GITHUB_CONNECTIONS`. | Read-only. |
| Granola (optional) | `query_granola_meetings` with a natural-language `query`; citation links are kept. | `find_tools` search can miss it; call it directly with `call_tool` (service `granola`). |
| Type | `read_workspace_artifact` (positioning document), `search_messages` (earlier runs), `materialize_skill`, sandbox `python3`, `upload_file`, `view_attachment`. | Renderer needs Python Playwright, Chromium and ffmpeg. |

## Reference implementation

### Automation instructions

Original provider assumptions: Slack tools from the Type Slack app, the X / Twitter connection, GitHub CLI access in the managed sandbox, the Granola connection, and the bundled skill below. **Reuse when these assumptions hold; adapt when they do not, preserving the behavior above.**

```text
Run the Daily Social Post Radar skill end to end.

- Window: everything since the previous run (about the last 24 to 48 hours).
- Load the current positioning fresh from the positioning document ({{input:positioning_document}}) before scoring.
- Sources: Slack ({{input:source_slack_channels}}), Granola call notes, and merged PRs in {{input:github_repository}}. Dedupe against recent {{input:company_x_handle}} posts and earlier runs of this task.
- Don't force it. If no candidate scores at least 8/10 with safety scored 2, the result is "No post today" plus the closest near-miss in one line.
- If a candidate qualifies: verify every fact against its primary source, write the X post (plus an optional first reply) and the LinkedIn post, render x.png, linkedin.mp4 and linkedin.png with the skill's render_post_graphics.py, visually QA them, and attach all three to the Type reply along with sources, "before posting" checks and suggested timing.
- NEVER publish to X or LinkedIn, and never post in any public Slack channel. {{input:reviewer_name}} reviews and posts themselves.

Deliver the result to {{input:reviewer_name}} personally in Slack, every run:
- Send a private DM to {{input:reviewer_name}} (Slack user {{input:reviewer_slack_user_id}}) with slack_send_dm, including on "No post today" days.
- Title line: the first line of every DM text message must be a short bold title, so {{input:reviewer_name}} knows which automation sent it. Post days: "*Daily post pick — <short story title>*". No-post days: "*Daily post pick — no post today*". Put the rest of the message on the following lines, unchanged.
- No-post days: one short DM, for example:
  "*Daily post pick — no post today*
  No post today. Closest: <near-miss> (<score>/10, why)."
- Post days: one DM starting with the title line, then a one-line pitch with the score, the X post and optional reply, the LinkedIn post (ready to paste), the "before posting" checks, suggested timing and source links. Then attach the graphics in the same DM conversation: get the DM channel ID from the slack_send_dm result (or slack_open_dm), upload each file with upload_file, then send x.png and linkedin.mp4 as separate slack_post_message calls to that DM channel with file_id. If a file upload to Slack fails, say so in the DM and point to the Type run for the files.
- Send each DM message once. If a send result is uncertain, check the DM history before retrying.

/daily-social-post-radar
```

### Bundled skill: `daily-social-post-radar`

Create these five files as one skill (see Setup). Placeholders are filled at setup; everything else is the source skill with company-specific values made configurable. The renderer was generalized only in how it reads brand values (`brand.json`, optional `spec.brand`); layout, limits, validation, animation and outputs are unchanged.

#### `SKILL.md`

````markdown
---
name: daily-social-post-radar
description: |-
  Daily scan of Slack, call notes and GitHub for one genuinely interesting, verifiable {{input:company_name}} story worth posting on X and LinkedIn, filtered through the current positioning. Returns nothing when nothing clears the bar. When something does, drafts X and LinkedIn copy and renders branded graphics (X still PNG plus LinkedIn 4:5 animated MP4) for approval. Never publishes.
---

# Daily Social Post Radar

Find **at most one** story worth posting today on the {{input:company_name}} X account and LinkedIn page, write the copy, and render the graphics. **If nothing clears the bar, say so in two lines and stop. Do not force a post.** A skipped day is a good outcome. A mediocre post is a bad one.

Never publish. Deliver a review package, and {{input:reviewer_name}} posts it themselves.

## 1. Load the current positioning (every run)

Positioning changes week to week, so read it fresh each run:
- Read the positioning document: `read_workspace_artifact({ artifactId: "{{input:positioning_document}}", mode: "full" })`.
- Skim {{input:positioning_channel_name}} (`{{input:positioning_channel_id}}`) for the last 7 days for positioning or profile-copy changes, such as the X bio wording.

Snapshot to use if the document is unreachable (refresh it whenever the document changes):
{{input:positioning_snapshot}}

## 2. Gather candidates (last ~48h, or since the previous run)

- **Slack:** use `slack_get_channel_history` with `oldest` set to the window start, on these channels:
{{input:source_slack_channels}}
  Open the threads on promising messages.
- **Calls:** `granola/query_granola_meetings` asking for surprising, quotable or concrete moments from the window, and keep its citation links.
- **GitHub (`{{input:github_repository}}`):** look for merged PRs in the window where the author is `{{input:agent_pr_author}}` (autonomous agent work) or the PR is a notable launch. Use `gh pr list -R {{input:github_repository}} --state merged --search "merged:>=YYYY-MM-DD" --json number,title,author,mergedAt` with the GitHub token env from `TYPE_GITHUB_CONNECTIONS`.
- **Dedupe:** check the last ~20 posts on {{input:company_x_handle}} with the `twitter` tools, plus earlier runs of this skill in the space (`search_messages` for "Daily Social Post Radar"). Never re-pitch a story.

## 3. Score, then decide whether to post at all

Score each candidate from 0 to 2 on each criterion:
1. **Positioning fit:** it shows the product doing real work reliably, on real systems, and supports the buyer story in the positioning document.
2. **Concrete and verifiable:** it has real times, counts or outcomes that you can confirm from a primary record (PR, Slack message, transcript).
3. **Surprising:** a reader from the target buyer audience would stop scrolling. "Strange new world" energy, not a feature announcement.
4. **Safe to share:** no unapproved customer names or quotes, no secrets or revenue, no security details that map our attack surface, nothing that reads as an incident or as reckless.
5. **Fresh:** not posted or pitched before.

**Post only if the total is at least 8 out of 10, with no zeros and safety scored 2.** Otherwise reply:
> **No post today.** Closest: <one line> (<score>/10, why it fell short).

Calibration example (illustrative, scored 9): an internal monitoring automation flagged untraceable blocked requests, wrote a logging-only fix, passed CI and merged its PR in about 96 minutes with no human review. It was technical, verifiable and on-message about trust.

## 4. Verify before writing

Re-check every number and time against its primary source. Use exact wording that the record supports. For example, if a human account appears anywhere in the PR history, write "no human review" rather than "no humans involved". Note anything unconfirmed, and anyone whose OK is needed (the engineer who owns an automation, or any customer mentioned).

## 5. Write the copy

{{input:brand_name_rule}}
- **X:** plain text of 4 to 6 short paragraphs. Open with what happened, give the concrete numbers, then one line of reassurance (scope or limits), then a short closer. No hashtags or emojis, no links in the main post, and no marketing voice. Add an optional first reply that answers the obvious skeptical question.
- **LinkedIn:** the same story, broken into scannable steps (→ bullets), with the reassurance in the body and a closing line about the business payoff.
- The text is the post and the graphic supports it. Never put the post copy inside the image.

## 6. Render the graphics

Write a spec JSON (format in `SPEC_FORMAT.md`; `example_spec.json` is an illustrative post). Call `managed-sandbox/materialize_skill` with this skill's ID, then from the returned directory run:

```
python3 render_post_graphics.py /abs/path/spec.json /abs/path/outdir
```

It prints JSON `{ok, font, brand, files:{x, linkedin_mp4, linkedin_png}, duration_s, warning?}`. On `ok:false` it gives a precise error, such as a line or label that's too long. Shorten the text and re-run.
- Dependencies: Python Playwright, Chromium and ffmpeg. If `import playwright` fails, run `python3 -m pip install --user playwright` (the script uses the system Chromium at `/usr/bin/chromium` when present; otherwise run `python3 -m playwright install chromium`).
- Brand colors, logo and font come from `brand.json` in this skill. Without a font file it falls back to DejaVu Sans Mono and says so in the output.
- **Visual QA is required:** upload `x.png` and `linkedin.png` as draft uploads (`upload_file` with `attachToCurrentMessage:false`), view them with `view_attachment`, and fix any clipping or awkward wrapping before delivering.

Visual style (keep it consistent):
- Monospace, box-drawn timeline card on the brand background gradient from `brand.json`.
- No marketing hero layout and no decorative headline bars.
- **Never fake code or logs:** no fake commands, log levels or commit syntax. Write plain-English steps in a terminal-like box.
- The brand logo (or wordmark) goes in the corner of the box. Accent-colored labels hold times or step markers. Tags in `[ ]` carry the reassurance.

## 7. Deliver

Attach `x.png`, `linkedin.mp4` and `linkedin.png` to the reply, and include:
1. A one-line pitch with the score, and why this story fits the positioning.
2. The X post and optional reply, plus the LinkedIn post, ready to paste.
3. Source links (Slack permalinks, PR URL, call-note citations).
4. **Before posting:** the facts you couldn't confirm and whose OK is needed.
5. Suggested timing: X between 9 and 11am, LinkedIn between 9 and 10am on weekdays ({{input:audience_timezone}}), an hour or two apart. Upload the MP4 to LinkedIn natively.

## Operational notes

- GitHub: set `GH_TOKEN` from the `tokenEnv` in `TYPE_GITHUB_CONNECTIONS` and run `gh` with `-R {{input:github_repository}}`. Before re-pitching a near-miss PR, check `gh pr view <n> --json state,mergedAt`.
- Earlier runs: `search_messages` with the query "No post today Closest" in the current space, then read the thread.
- Slack history: `slack_get_channel_history` with `oldest` set to the previous run's epoch seconds, then open threads with `slack_get_thread_replies` (`channel_id`, `thread_ts`). `slack_get_message_permalink` takes `channel_id` and `timestamp` (not `message_ts`).
- Granola: `find_tools` query search can miss it; call `query_granola_meetings` directly via `call_tool` (service `granola`) with a natural-language `query` argument.
- X dedupe: `twitter_search_recent_posts` with the query `from:` followed by the {{input:company_x_handle}} handle without the leading `@`.

## Improving this skill

Reuse `render_post_graphics.py`. Don't rebuild the graphics by hand. If {{input:reviewer_name}} rejects a post or asks for visual changes, update this file (scoring bar, style rules), `brand.json` or the renderer, test it with `example_spec.json`, and save it. If a new layout is genuinely needed (for example a quote or a single stat), add it as a spec option in the renderer instead of building a one-off.
````

#### `SPEC_FORMAT.md`

````markdown
# Graphic spec format (render_post_graphics.py)

Read this when writing the spec JSON for a post.

```json
{
  "header":  "{{input:company_name}} automation · Oct 19 · UTC",   // required, first line in the box
  "rows": [                                             // required, 2-5 rows
    {"label": "15:22", "text": "Flagged blocked admin requests", "detail": "all denied, sender unknown"},
    {"label": "16:58", "text": "Merged ✓"}
  ],
  "tags":    ["Logging only · No behavior changes", "0 human reviewers"],   // optional, max 3; first is green
  "caption": "an agent, a security gap, and no human review",              // optional, LinkedIn video only (top)
  "counter": {"prefix": "flagged → merged in", "suffix": "min", "values": [0, 96]},  // optional, LinkedIn only; one value per row
  "brand":   {"accent": "#e0218a"}                       // optional per-run override of brand.json keys
}
```

## Limits (enforced; the script fails with a precise message)
- `label`: max 6 chars. Use a time (`15:22`), a relative time (`+26m`), a step (`01`), or a short metric (`3x`).
- Row `text` and `(detail)`: max ~42 chars each. Every line must fit in a 52-char box.
- Tags render as `[ tag ]`. Keep them under ~46 chars.

## Content rules
- Plain English only. No fake commands, log levels, stack traces or commit syntax.
- Every label and number must match a primary source. Use `~` or omit a label when a time is approximate.
- Use the first tag for the reassurance or scope (what the agent was and wasn't allowed to do), and the second for the striking stat.
- Follow the brand naming rule. No customer names unless the customer has approved.
- `counter.values` animate in step with the rows (for example minutes elapsed). Omit it if there's no natural running number.

## brand.json keys
`name` (wordmark text when no logo), `background` (CSS gradient), `accent`, `ok` (first tag), `ink`, `heading`,
`muted`, `caption` (CSS colors), `logo_svg_path` + `logo_viewbox` (optional single-path SVG logo),
`font_path` (optional monospace .woff2/.woff/.ttf/.otf file reachable in the sandbox).
````

#### `example_spec.json`

```json
{
  "header": "{{input:company_name}} automation · Oct 19 · UTC",
  "rows": [
    {"label": "15:22", "text": "Flagged blocked admin requests", "detail": "all denied, sender unknown"},
    {"label": "16:14", "text": "Wrote fix + opened PR", "detail": "logs who sent each denied request"},
    {"label": "16:57", "text": "CI passed", "detail": "unit, integration, end-to-end"},
    {"label": "16:58", "text": "Merged ✓"}
  ],
  "tags": ["Logging only · No behavior changes", "0 human reviewers"],
  "caption": "an agent, a security gap, and no human review",
  "counter": {"prefix": "flagged → merged in", "suffix": "min", "values": [0, 52, 95, 96]}
}
```

#### `brand.json`

```json
{
  "name": "{{input:company_name}}",
  "background": "{{input:brand_background_gradient}}",
  "accent": "{{input:brand_accent_color}}",
  "ok": "#17673a",
  "ink": "#2a2d36",
  "heading": "#15171c",
  "muted": "#7a7f8c",
  "caption": "#5f6472",
  "logo_svg_path": "{{input:brand_logo_svg_path}}",
  "logo_viewbox": "{{input:brand_logo_svg_viewbox}}",
  "font_path": ""
}
```

#### `render_post_graphics.py`

```python
"""Render branded social graphics from a JSON spec.

Outputs (in OUTDIR):
  x.png          1600x900 @2x (3200x1800) still for X
  linkedin.mp4   1080x1350 (4:5) animated version for LinkedIn
  linkedin.png   final frame of the LinkedIn animation (fallback still)

Usage:
  python3 render_post_graphics.py SPEC.json OUTDIR [--x-only | --linkedin-only] [--stills T1 T2 ...]

Requires: python playwright, chromium (/usr/bin/chromium or PLAYWRIGHT default), ffmpeg.
Spec format: see SPEC_FORMAT.md. Brand (colors, logo, font): brand.json next to this script,
overridden per run by an optional "brand" object in the spec.
"""
import argparse, html, json, os, shutil, subprocess, sys, tempfile

LABEL_W = 8          # label column width ("15:22   ")
MIN_W, MAX_W = 40, 52  # inner box width in characters
BRAND_DEFAULTS = {
    "name": "",                     # wordmark text shown when no logo SVG is configured
    "background": "linear-gradient(to bottom,#eef2ff,#f8fafc,#ffffff)",
    "accent": "#4f46e5",            # dots, time labels, typing cursor, counter number
    "ok": "#17673a",                # first tag (the reassurance)
    "ink": "#2a2d36",               # body text and box frame
    "heading": "#15171c",           # header line and logo
    "muted": "#7a7f8c",             # detail lines
    "caption": "#5f6472",           # LinkedIn caption
    "logo_svg_path": "",            # optional: SVG path data for the logo
    "logo_viewbox": "0 0 112 35",   # viewBox for logo_svg_path
    "font_path": "",                # optional: monospace .woff2/.woff/.ttf/.otf
}


def load_brand(spec):
    brand = dict(BRAND_DEFAULTS)
    path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "brand.json")
    configured = False
    if os.path.exists(path):
        try:
            brand.update({k: v for k, v in json.load(open(path)).items() if k in BRAND_DEFAULTS and v not in (None,)})
            configured = True
        except ValueError:
            fail(f"brand.json is not valid JSON: {path}")
    override = spec.get("brand") or {}
    if override:
        brand.update({k: v for k, v in override.items() if k in BRAND_DEFAULTS and v is not None})
        configured = True
    return brand, configured


def find_font(brand):
    for p in [brand.get("font_path", ""), os.environ.get("POST_FONT_PATH", "")]:
        if p and os.path.exists(os.path.expanduser(p)):
            return os.path.expanduser(p)
    return None


def fail(msg):
    print(json.dumps({"ok": False, "error": msg}))
    sys.exit(1)


def validate(spec):
    if not spec.get("header"):
        fail("spec.header is required")
    rows = spec.get("rows") or []
    if not 2 <= len(rows) <= 5:
        fail("spec.rows must have 2-5 entries")
    for r in rows:
        if len(r.get("label", "")) > LABEL_W - 2:
            fail(f"label too long (max {LABEL_W-2} chars): {r.get('label')}")
        if not r.get("text"):
            fail("every row needs text")
    if len(spec.get("tags") or []) > 3:
        fail("max 3 tags")
    lines = [spec["header"]] + [" " * LABEL_W + r["text"] for r in rows]
    lines += [" " * LABEL_W + f"({r['detail']})" for r in rows if r.get("detail")]
    lines += [f"[ {t} ]" for t in spec.get("tags") or []]
    longest = max(len(l) for l in lines) + 2  # + gutter
    if longest > MAX_W:
        bad = [l.strip() for l in lines if len(l) + 2 > MAX_W]
        fail(f"lines too long for the box (max {MAX_W - 2 - LABEL_W} chars of row text): {bad}")
    return max(MIN_W, longest)


def build_rows(spec):
    """Return list of (mark, text, cls, t0, mode) with timings for the animation."""
    out = [("", spec["header"], "hdr", 0.5, "type"), ("", "", None, 0, None)]
    t = 1.4
    starts = []
    rows = spec["rows"]
    for i, r in enumerate(rows):
        starts.append(t)
        out.append(("●", r["label"].ljust(LABEL_W) + r["text"], "s", t, "type"))
        t += 0.25 + len(r["text"]) / 40 + 0.2
        if r.get("detail"):
            out.append(("│" if i < len(rows) - 1 else "", " " * LABEL_W + f"({r['detail']})", "d", t, "fade"))
            t += 0.35
        if i < len(rows) - 1:
            out.append(("│", "", None, t, "rail"))
            t += 0.35
    out.append(("", "", None, 0, None))
    t += 0.6
    for j, tag in enumerate(spec.get("tags") or []):
        out.append(("", f"[ {tag} ]", "pill" if j == 0 else "pill2", t, "type"))
        t += 0.3 + len(tag) / 40 + 0.2
    out.append(("", "", None, 0, None))
    return out, starts, t + 0.3


def box_html(rows, W, animated):
    e = html.escape
    def seg(cls, t0, mode, text):
        if not animated:
            return f'<span class="{cls}">{e(text)}</span>'
        return f'<span class="{cls} seg" data-t="{t0}" data-m="{mode}" data-s="{e(text)}">{e(text)}</span>'
    lines = ['<span class="frame">┌' + "─" * (W + 2) + "┐</span>"]
    for mark, text, cls, t0, mode in rows:
        body = ((mark + " ") if mark else "  ") + text
        pad = W - len(body)
        if mark == "●":
            m = (f'<span class="dot seg" data-t="{t0}" data-m="pop">●</span> ' if animated else '<span class="dot">●</span> ')
        elif mark == "│":
            m = (f'<span class="rail seg" data-t="{t0}" data-m="fade">│</span> ' if animated else '<span class="rail">│</span> ')
        else:
            m = "  "
        if cls == "s":
            lead, rest = text[:LABEL_W], text[LABEL_W:]
            t = seg("tm", t0, "type", lead) + seg("txt", t0 + 0.15, "type", rest)
        elif cls:
            t = seg(cls, t0, mode, text)
        else:
            t = ""
        lines.append('<span class="frame">│</span> ' + m + t + " " * pad + ' <span class="frame">│</span>')
    lines.append('<span class="frame">│</span>' + " " * (W + 2) + '<span class="frame">│</span>')
    lines.append('<span class="frame">└' + "─" * (W + 2) + "┘</span>")
    return "\n".join(lines)


CSS = """
@font-face{font-family:GM;src:url(%(font_file)s) format("%(font_format)s");font-weight:100 900}
*{margin:0;padding:0;box-sizing:border-box}
body{overflow:hidden;position:relative;background:%(bg)s}
.card{position:absolute;left:50%%;top:50%%;transform:translate(-50%%,-50%%);background:rgba(255,255,255,.72);
 border:1px solid rgba(255,255,255,.9);border-radius:22px;box-shadow:0 30px 80px rgba(40,46,63,.18),0 2px 6px rgba(40,46,63,.06)}
pre{font-family:GM,"DejaVu Sans Mono",monospace;color:%(ink)s;font-weight:420}
.dot{color:%(accent)s;display:inline-block}.rail{color:#b9bcc6}.tm{color:%(accent)s;font-weight:560}
.hdr{font-weight:600;color:%(heading)s}.d{color:%(muted)s}.pill{color:%(ok)s;font-weight:560}.pill2{color:%(ink)s;font-weight:560}
.frame{color:%(ink)s}.logo{position:absolute;color:%(heading)s}
.wordmark{position:absolute;color:%(heading)s;font-family:GM,"DejaVu Sans Mono",monospace;font-weight:600;white-space:nowrap}
.cur{background:%(accent)s;color:transparent}.hid{color:transparent}
.cap{position:absolute;left:0;right:0;text-align:center;font-family:GM,monospace;color:%(caption)s;letter-spacing:.04em}
.counter{position:absolute;left:0;right:0;text-align:center;font-family:GM,monospace;color:%(ink)s}
.counter b{color:%(accent)s;font-weight:600}
"""

ANIM_JS = """
const CPS=40;const segs=[...document.querySelectorAll('.seg')];
const esc=s=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const clamp=x=>Math.max(0,Math.min(1,x));const ease=x=>1-Math.pow(1-x,3);
const LOGO_T=%(logo_t)s;const KEYS=%(keys)s;
window.setT=function(t){
  const card=document.getElementById('card');const a=ease(clamp(t/0.5));
  card.style.opacity=a;card.style.transform=`translate(-50%%,calc(-50%% + ${(1-a)*24}px))`;
  const cap=document.getElementById('cap'); if(cap) cap.style.opacity=ease(clamp((t-0.2)/0.6));
  for(const s of segs){const t0=+s.dataset.t,m=s.dataset.m;
    if(m==='type'){const str=s.dataset.s;const n=Math.max(0,Math.min(str.length,Math.floor((t-t0)*CPS)));
      if(t<t0){s.innerHTML='<span class="hid">'+esc(str)+'</span>';}
      else if(n<str.length){s.innerHTML=esc(str.slice(0,n))+'<span class="cur">'+esc(str[n])+'</span><span class="hid">'+esc(str.slice(n+1))+'</span>';}
      else s.innerHTML=esc(str);}
    else if(m==='fade'){s.style.opacity=ease(clamp((t-t0)/0.35));}
    else if(m==='pop'){const p=clamp((t-t0)/0.3);s.style.opacity=p;s.style.transform=`scale(${0.3+0.7*ease(p)+0.25*Math.sin(p*Math.PI)})`;}}
  document.getElementById('logo').style.opacity=ease(clamp((t-LOGO_T)/0.5));
  const c=document.getElementById('counter');
  if(c){let v=null;for(let i=0;i<KEYS.length;i++){if(t>=KEYS[i][0]){const prev=i?KEYS[i-1][1]:KEYS[0][1];
      v=prev+(KEYS[i][1]-prev)*ease(clamp((t-KEYS[i][0])/0.6));}}
    c.style.opacity=ease(clamp((t-1.2)/0.5));document.getElementById('cv').textContent=v===null?KEYS[0][1]:Math.round(v);}
};
setT(0);
"""


def page(spec, brand, font_file, W, size, font_px, pad, logo_h, animated, caption=None, counter=None, logo_t=0, keys="[]"):
    w, h = size
    rows, starts, end = build_rows(spec)
    pre = box_html(rows, W, animated)
    pos = f"right:{pad[1]+44}px;bottom:{pad[0]+int(font_px*1.55)+8}px"
    if brand.get("logo_svg_path"):
        logo = (f'<svg class="logo" id="logo" style="{pos};height:{logo_h}px" '
                f'viewBox="{html.escape(brand.get("logo_viewbox") or "0 0 112 35")}" fill="currentColor">'
                f'<path d="{html.escape(brand["logo_svg_path"])}"/></svg>')
    elif brand.get("name"):
        logo = f'<span class="wordmark" id="logo" style="{pos};font-size:{int(logo_h*0.8)}px">{html.escape(brand["name"])}</span>'
    else:
        logo = '<span id="logo"></span>'
    ext = os.path.splitext(font_file or "")[1].lower().lstrip(".")
    css_vars = {"bg": brand["background"], "accent": brand["accent"], "ok": brand["ok"], "ink": brand["ink"],
                "heading": brand["heading"], "muted": brand["muted"], "caption": brand["caption"],
                "font_file": font_file or "none.woff2",
                "font_format": {"woff2": "woff2", "woff": "woff", "ttf": "truetype", "otf": "opentype"}.get(ext, "woff2")}
    extra = ""
    if caption:
        extra += f'<div class="cap" id="cap" style="top:{int(h*0.11)}px;font-size:{int(font_px*0.85)}px">{html.escape(caption)}</div>'
    if counter:
        extra += (f'<div class="counter" id="counter" style="bottom:{int(h*0.11)}px;font-size:{int(font_px*0.92)}px">'
                  f'{html.escape(counter["prefix"])} <b id="cv">0</b> {html.escape(counter.get("suffix",""))}</div>')
    js = f"<script>{ANIM_JS % {'logo_t': logo_t, 'keys': keys}}</script>" if animated else ""
    return f"""<!DOCTYPE html><html><head><meta charset="utf-8"><style>{CSS % css_vars}
body{{width:{w}px;height:{h}px}} pre{{font-size:{font_px}px;line-height:1.5}} .card{{padding:{pad[0]}px {pad[1]}px}}</style></head>
<body>{extra}<div class="card" id="card"><pre>{pre}</pre>{logo}</div>{js}</body></html>"""


def chromium_path():
    for p in ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome"]:
        if os.path.exists(p):
            return p
    return None


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("spec"); ap.add_argument("outdir")
    ap.add_argument("--x-only", action="store_true"); ap.add_argument("--linkedin-only", action="store_true")
    ap.add_argument("--stills", nargs="*", type=float, help="render LinkedIn stills at these seconds instead of video")
    a = ap.parse_args()
    spec = json.load(open(a.spec))
    W = validate(spec)
    os.makedirs(a.outdir, exist_ok=True)
    work = tempfile.mkdtemp(prefix="postgfx-")
    brand, branded = load_brand(spec)
    font = find_font(brand)
    font_file = None
    if font:
        font_file = "brandfont" + os.path.splitext(font)[1].lower()
        shutil.copy(font, os.path.join(work, font_file))
    from playwright.sync_api import sync_playwright
    result = {"ok": True, "font": os.path.basename(font) if font else "fallback (DejaVu Sans Mono)",
              "brand": "brand.json/spec" if branded else "defaults (no brand.json)", "files": {}}
    with sync_playwright() as p:
        kw = {"executable_path": chromium_path()} if chromium_path() else {}
        b = p.chromium.launch(**kw)
        if not a.linkedin_only:
            # scale font so the box fills ~60% of width
            n_lines = len(build_rows(spec)[0]) + 3
            fx = min(27, int(1600 * 0.58 / ((W + 4) * 0.6)), int((900 - 150) / (n_lines * 1.5)))
            open(f"{work}/x.html", "w").write(page(spec, brand, font_file, W, (1600, 900), fx, (34, 40), 26, False))
            pg = b.new_page(viewport={"width": 1600, "height": 900}, device_scale_factor=2)
            pg.goto(f"file://{work}/x.html"); pg.wait_for_timeout(600)
            out = os.path.join(a.outdir, "x.png"); pg.screenshot(path=out); result["files"]["x"] = out
            box = pg.eval_on_selector("#card", "e=>{const r=e.getBoundingClientRect();return [r.top,r.bottom]}")
            if box[0] < 20 or box[1] > 880:
                result["warning"] = "X card nearly overflows the frame; shorten rows"
            pg.close()
        if not a.x_only:
            rows, starts, end = build_rows(spec)
            logo_t = end - 0.2
            counter = spec.get("counter")
            keys = "[]"
            if counter:
                vals = counter["values"]
                if len(vals) != len(spec["rows"]):
                    fail("counter.values must have one number per row")
                keys = json.dumps([[s, v] for s, v in zip(starts, vals)])
            n_lines = len(rows) + 3
            fl = min(28, int(1080 * 0.84 / ((W + 4) * 0.6)), int((1350 * 0.62) / (n_lines * 1.5)))
            open(f"{work}/li.html", "w").write(page(spec, brand, font_file, W, (1080, 1350), fl, (38, 40), 30, True,
                                                   spec.get("caption"), counter, logo_t, keys))
            pg = b.new_page(viewport={"width": 1080, "height": 1350}, device_scale_factor=1)
            pg.goto(f"file://{work}/li.html"); pg.wait_for_timeout(600)
            dur = logo_t + 4.5  # hold the finished card ~4s
            if a.stills:
                for i, t in enumerate(a.stills):
                    pg.evaluate(f"setT({t})"); pth = os.path.join(a.outdir, f"still_{i}.png")
                    pg.screenshot(path=pth); result["files"][f"still_{t}"] = pth
            else:
                fdir = os.path.join(work, "frames"); os.makedirs(fdir)
                n = int(dur * 30)
                for i in range(n):
                    pg.evaluate(f"setT({i/30})"); pg.screenshot(path=f"{fdir}/f{i:05d}.png")
                shutil.copy(f"{fdir}/f{n-1:05d}.png", os.path.join(a.outdir, "linkedin.png"))
                mp4 = os.path.join(a.outdir, "linkedin.mp4")
                subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", "30", "-i", f"{fdir}/f%05d.png",
                                "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "16", "-preset", "slow",
                                "-movflags", "+faststart", mp4], check=True)
                result["files"].update({"linkedin_mp4": mp4, "linkedin_png": os.path.join(a.outdir, "linkedin.png")})
                result["duration_s"] = round(dur, 1)
            pg.close()
        b.close()
    shutil.rmtree(work, ignore_errors=True)
    print(json.dumps(result))


if __name__ == "__main__":
    main()
```

## Verification

**Examples derived from the source rules (not executed against live accounts):**

- Best candidate scores 7/10, or 9/10 with safety 1: the DM is "*Daily post pick — no post today*" with the closest near-miss and why.
- A qualifying story already posted on the company X account: it is dropped as not fresh.
- A PR history that includes a human account: the copy says "no human review", not "no humans involved".
- A renderer error for a row longer than the box: the text is shortened and the render repeated.

**Renderer checks run on the bundled script (local sandbox, synthetic brand values):**

1. `example_spec.json` with a configured `brand.json` (wordmark, custom gradient and accent): `ok: true`; `x.png` 3200×1800, `linkedin.mp4` 1080×1350 at 14.5s, `linkedin.png` 1080×1350. Passed; layout visually checked.
2. No `brand.json` (defaults), X only: `ok: true`, reports `defaults (no brand.json)`. Passed.
3. Per-run `spec.brand` override with an SVG logo path: `ok: true`. Passed.
4. A 60-character row: `ok: false` with "lines too long for the box (max 42 chars of row text)". Passed.

**Proposed acceptance checks after installation:**

1. Run once in a test thread with the reviewer's own Slack ID; confirm exactly one DM with the bold title line.
2. On a post day, confirm the DM carries `x.png` and `linkedin.mp4`, and the Type run has all three files.
3. Make the positioning document unreadable; confirm the snapshot is used and the run still completes.
4. Run twice on the same window; confirm the second run does not re-pitch the first run's story.