Features
Posts

Posts

A post is a LinkedIn post draft moving through lk-wiz's content pipeline: draft → in_review → revision_requested → approved → scheduled → published → archived. Posts are where writing actually happens — the canvas editor, real-time collaboration, version history, and image attachments all hang off a post record.

This page describes the product-facing behavior. For the exact request/response shapes and error codes, see the Posts API reference. For programmatic access from an AI client, see Posts MCP tools. The underlying spec this page is based on is specs/features/posts.md in the repo.

This page covers the five post capabilities end to end.

Create

You get a post in one of two ways:

  • From scratch — click "New Post" on the pipeline board, give it a title, start typing.
  • From an idea — click "Generate Post" on a triaged idea; GenAI drafts the content, and on save the new post is linked back to the idea (source_idea_id) and the idea flips to converted.

Either way, the post starts life as a draft at p3_medium priority, version 1, with an empty (or GenAI-seeded) canvas that's immediately collaborative — open it in two browser tabs and you'll see both cursors.

POST /api/posts
{
  "workspace_id": "ws_123",
  "title": "Why observability matters",
  "content": "<p>Optional starting content</p>",
  "source_idea_id": "idea_456",
  "tags": ["engineering"]
}
Why is there a yjs_state field?

When the frontend creates a post from GenAI-generated content, it encodes the initial editor state as a raw Yjs update (Y.encodeStateAsUpdate(doc)) and base64s it into yjs_state. The backend wraps that as a y-protocol syncStep2 message so the very first WebSocket client to open the post receives the generated draft, not a blank document. If you don't pass it (or pass something unparseable), the post just starts with an empty collaborative document — creation never fails because of it.

Manage

The pipeline board is a kanban view of a workspace's posts, one column per status. Cards show title, author, priority badge, and character count; drag a card between columns to change its status (respecting the transition rules below), or drag within a column to reorder (persisted via POST /api/posts/reorder).

Status transitions

Two transitions are special: draft → in_review and in_review → approved each automatically snapshot a version of the content before the status changes, so you can always see exactly what was submitted or approved (see Versions below). Requesting a revision requires a comment explaining what needs to change; scheduling requires a target date/time.

Any workspace member can drive a status transition through PUT /api/posts/{id}/status — the only enforcement is the state-machine table itself. Role restrictions kick in specifically for the dedicated schedule and publish actions, and for deleting a non-draft/archived post (admin/owner only).

Editing & deleting

Editing title, content, tags, priority, reviewer, or LinkedIn @-tags (tagged_entities) is a partial update — send only the fields you're changing. Deleting is restricted by post status, not by ownership: any workspace member can delete a draft or archived post (regardless of who authored it), but only admins/owners can delete a post mid-pipeline (in_review, approved, scheduled, published) — there's no separate "only the author can delete their own draft" check.

Publish

Once a post is approved, it's ready to leave the drafting stage. lk-wiz doesn't publish to LinkedIn directly — actual publishing happens in Zoho Social, which owns the LinkedIn API integration. lk-wiz's job is to manage the content pipeline and the weekly publishing calendar up to that point.

Two ways an approved post moves forward:

  1. Schedule itPOST /api/posts/{id}/schedule with a scheduled_at slot. This can be a slot the system auto-assigned (a daily background job sets scheduled_at to roughly one day out for any approved post that doesn't have one yet — it isn't a weekly-calendar lookup) or a custom date/time you pick yourself.
  2. Publish it immediatelyPOST /api/posts/{id}/publish, once you've manually copied the content into Zoho Social and it's live there.

Both actions are gated to the post's author, an editor, or an owner — a workspace viewer cannot schedule or publish.

What happens around a scheduled slot, handled by a separate background job, not this API:

  • 2 days before the slot: the job logs a reminder and emits a SlackNotificationSent metric — it does not actually send a Slack DM to the author today.
  • 24 hours after a slot passes with the post still scheduled: the job logs a warning and emits a MissedSchedule metric — again, no Slack message is sent. (The Slack-sending code for these events exists elsewhere in the codebase, but the scheduler job doesn't call it.)

Versions

Every meaningful change to a post's content is recoverable. Open Version History from the editor toolbar to see a chronological list of snapshots, each tagged with who made it, when, and why:

TriggerFires when
manualYou click "Save Version" in the editor
submit_reviewThe post moves draft → in_review
approvedThe post moves in_review → approved
genai_acceptYou accept a GenAI rewrite suggestion (recorded by the GenAI domain, same version shape)

Click any version to diff it against the current content; "Restore" first snapshots the current content (so restoring never destroys work) and then replaces the editor content with the selected version.

GET /api/posts/{id}/versions?workspaceId=ws_123
⚠️

Reading version history requires more than plain membership — workspace viewers cannot view version history (403). Creating a manual version only requires membership.

Images

Attach images to a post (charts, screenshots, graphics) for use when copying the post to Zoho Social.

  1. Ask for an upload slot: POST /api/posts/{id}/images { "filename": "chart.png", "content_type": "image/png" } → returns a presigned S3 upload_url valid for one hour. Allowed types: PNG, JPG/JPEG, GIF, WEBP.
  2. PUT the raw file bytes straight to upload_url from the browser — the file never transits through Lambda.
  3. GET /api/posts/{id}/images lists everything attached, each with a freshly generated presigned download URL (also one-hour, regenerated every time you list — never cached server-side).
  4. Delete via DELETE /api/posts/{id}/images/{image_id} — allowed for whoever uploaded it, the post's author, or an admin/owner.

See also