docs(01-linkedin-video-upload): create phase plan
Phase 01: LinkedIn video upload support - 2 plans created (01-01, 01-02) - 5 tasks total: client upload flow, createVideoPost, MCP tool definition, REST route, e2e verify checkpoint - Ready for execution
This commit is contained in:
162
.planning/phases/01-linkedin-video-upload/01-01-PLAN.md
Normal file
162
.planning/phases/01-linkedin-video-upload/01-01-PLAN.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
---
|
||||||
|
phase: 01-linkedin-video-upload
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/clients/linkedin.ts
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Implement the LinkedIn Videos API upload flow inside src/clients/linkedin.ts.
|
||||||
|
|
||||||
|
Purpose: The existing linkedin client only supports text posts. LinkedIn video upload
|
||||||
|
requires a 4-step REST API flow that differs from the v2 UGC posts API. This plan
|
||||||
|
implements the complete upload pipeline as two exported functions.
|
||||||
|
|
||||||
|
Output: Two new exported functions — `uploadVideo()` and `createVideoPost()` — that
|
||||||
|
handle downloading a video from a public URL, chunking it, uploading it to LinkedIn,
|
||||||
|
and publishing a post with the video attached.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
~/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
./summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@src/clients/linkedin.ts
|
||||||
|
|
||||||
|
**Established patterns in this file:**
|
||||||
|
- `resolveToken(args, customer)` handles both multi-tenant (Redis/OAuth) and env-var auth
|
||||||
|
- `linkedinRequest(endpoint, token, method, body)` wraps fetch for `/v2` API calls
|
||||||
|
- All exported functions accept `{ account?: string }` and optional `Customer`
|
||||||
|
- `createToolAudit(customer.id, 'linkedin:action')` wraps calls for audit logging
|
||||||
|
- Errors bubble as thrown `Error` instances (not wrapped in result objects)
|
||||||
|
|
||||||
|
**LinkedIn Videos API facts (different from v2):**
|
||||||
|
- Base: `https://api.linkedin.com/rest` (not `/v2`)
|
||||||
|
- Required extra header: `LinkedIn-Version: 202501`
|
||||||
|
- Init: `POST /rest/videos?action=initializeUpload`
|
||||||
|
- Chunk upload: PUT to each `uploadUrl` from init response, `Content-Type: application/octet-stream`
|
||||||
|
- ETag collected from each PUT response's `ETag` header
|
||||||
|
- Finalize: `POST /rest/videos?action=finalizeUpload`
|
||||||
|
- Poll: `GET /rest/videos/{encodedVideoUrn}` until `status === 'AVAILABLE'`
|
||||||
|
- Post: `POST /rest/posts` (not `/v2/ugcPosts`) — post ID returned in `x-restli-id` response header
|
||||||
|
- Chunk size: 4 MB (4,194,304 bytes); LinkedIn enforces min 2 MB except last chunk
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add linkedinRestRequest helper and uploadVideo() function</name>
|
||||||
|
<files>src/clients/linkedin.ts</files>
|
||||||
|
<action>
|
||||||
|
Add a private `linkedinRestRequest` helper (mirrors `linkedinRequest` but uses
|
||||||
|
`https://api.linkedin.com/rest` base and adds `LinkedIn-Version: 202501` header).
|
||||||
|
Responses from init/finalize return 200 with JSON body; PUT chunk responses may return
|
||||||
|
200 or 204; always read ETag from the response header, not the body.
|
||||||
|
|
||||||
|
Then add `uploadVideo(videoUrl, ownerUrn, accessToken): Promise<string>` (returns video URN):
|
||||||
|
|
||||||
|
Step 1 — Download: fetch the videoUrl, read as ArrayBuffer, get byte length.
|
||||||
|
Use AbortSignal.timeout(120000) for large files.
|
||||||
|
|
||||||
|
Step 2 — Initialize upload:
|
||||||
|
POST /rest/videos?action=initializeUpload
|
||||||
|
Body: { initializeUploadRequest: { owner: ownerUrn, fileSizeBytes: N, uploadCaptions: false, uploadThumbnail: false } }
|
||||||
|
Extract from response.value: uploadInstructions (array), video (URN string), uploadToken.
|
||||||
|
|
||||||
|
Step 3 — Upload chunks:
|
||||||
|
Chunk size: 4,194,304 bytes. For each uploadInstruction, slice the buffer from
|
||||||
|
firstByte to lastByte+1 and PUT to uploadUrl with Content-Type: application/octet-stream.
|
||||||
|
Collect the ETag response header from each PUT (strip surrounding quotes if present).
|
||||||
|
Use AbortSignal.timeout(60000) per chunk.
|
||||||
|
|
||||||
|
Step 4 — Finalize:
|
||||||
|
POST /rest/videos?action=finalizeUpload
|
||||||
|
Body: { finalizeUploadRequest: { video: videoUrn, uploadToken, uploadedPartIds: [etag1, etag2, ...] } }
|
||||||
|
|
||||||
|
Step 5 — Poll until AVAILABLE:
|
||||||
|
GET /rest/videos/{encodeURIComponent(videoUrn)}
|
||||||
|
Poll every 3 seconds, timeout after 90 seconds total, throw if status is 'FAILED'.
|
||||||
|
Return videoUrn when status === 'AVAILABLE'.
|
||||||
|
</action>
|
||||||
|
<verify>npx tsc --noEmit runs without errors after adding the function</verify>
|
||||||
|
<done>uploadVideo is exported-ready (used by Task 2), tsc clean</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Add createVideoPost() exported function</name>
|
||||||
|
<files>src/clients/linkedin.ts</files>
|
||||||
|
<action>
|
||||||
|
Add and export:
|
||||||
|
|
||||||
|
```
|
||||||
|
createVideoPost(
|
||||||
|
args: { video_url: string; text: string; visibility?: 'PUBLIC' | 'CONNECTIONS'; account?: string },
|
||||||
|
customer?: Customer
|
||||||
|
): Promise<{ success: boolean; post_id: string; url: string }>
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
1. Resolve token with existing `resolveToken(args, customer)`.
|
||||||
|
2. Get profile with existing `getProfile(args, customer)` to build `authorUrn`.
|
||||||
|
3. Call `uploadVideo(args.video_url, authorUrn, accessToken)` → videoUrn.
|
||||||
|
4. Create post via POST /rest/posts (NOT /v2/ugcPosts — that endpoint does not support
|
||||||
|
the video content field):
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
author: authorUrn,
|
||||||
|
commentary: args.text,
|
||||||
|
visibility: args.visibility ?? 'PUBLIC',
|
||||||
|
distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
|
||||||
|
content: { media: { id: videoUrn } },
|
||||||
|
lifecycleState: 'PUBLISHED',
|
||||||
|
isReshareDisabledByAuthor: false
|
||||||
|
}
|
||||||
|
5. The POST /rest/posts returns 201; the post URN is in the `x-restli-id` response header
|
||||||
|
(not the JSON body). Read it with `res.headers.get('x-restli-id')`.
|
||||||
|
6. Wrap in createToolAudit (same pattern as createPost — log text.slice(0,100) + video_url).
|
||||||
|
7. Return { success: true, post_id, url: `https://www.linkedin.com/feed/update/${post_id}` }.
|
||||||
|
|
||||||
|
Do NOT call linkedinRestRequest for the POST /rest/posts step — write an inline fetch
|
||||||
|
since that call needs to read the 201 response header before res.json() would consume it.
|
||||||
|
Throw a descriptive error if status is not 201.
|
||||||
|
</action>
|
||||||
|
<verify>npx tsc --noEmit passes; grep -n 'createVideoPost' src/clients/linkedin.ts shows the export</verify>
|
||||||
|
<done>createVideoPost is exported, tsc clean, audit logging included</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Before declaring plan complete:
|
||||||
|
- [ ] `npx tsc --noEmit` passes with zero errors
|
||||||
|
- [ ] `grep -n 'uploadVideo\|createVideoPost\|linkedinRestRequest' src/clients/linkedin.ts` shows all three symbols
|
||||||
|
- [ ] No existing functions (`getProfile`, `createPost`, `searchConnections`, `sendMessage`) are modified
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- uploadVideo() implements all 5 steps: download, init, chunk-PUT with ETag collection, finalize, poll
|
||||||
|
- createVideoPost() uses /rest/posts (not /v2/ugcPosts) and reads post ID from x-restli-id header
|
||||||
|
- Chunk size is 4,194,304 bytes
|
||||||
|
- Poll timeout is 90 seconds with 3-second intervals
|
||||||
|
- TypeScript compiles clean
|
||||||
|
- Audit logging matches existing createPost pattern
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-linkedin-video-upload/01-01-SUMMARY.md`:
|
||||||
|
|
||||||
|
# Phase 01 Plan 01: LinkedIn Video Upload Client Summary
|
||||||
|
|
||||||
|
**[one-liner of what shipped]**
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
## Files Created/Modified
|
||||||
|
## Decisions Made
|
||||||
|
## Issues Encountered
|
||||||
|
## Next Step
|
||||||
|
Ready for 01-02-PLAN.md
|
||||||
|
</output>
|
||||||
177
.planning/phases/01-linkedin-video-upload/01-02-PLAN.md
Normal file
177
.planning/phases/01-linkedin-video-upload/01-02-PLAN.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
---
|
||||||
|
phase: 01-linkedin-video-upload
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
depends_on: ["01-01"]
|
||||||
|
files_modified:
|
||||||
|
- src/tools.ts
|
||||||
|
- src/index.ts
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Wire createVideoPost() into the MCP tool layer and REST API.
|
||||||
|
|
||||||
|
Purpose: The upload client from Plan 01 is only callable internally. This plan exposes
|
||||||
|
it as a named MCP tool (`linkedin_upload_video`) and a REST endpoint
|
||||||
|
(`POST /api/linkedin/video`) so Claude and external callers can use it.
|
||||||
|
|
||||||
|
Output: A fully registered MCP tool + REST route that accepts video_url, text, visibility,
|
||||||
|
and account, and returns { success, post_id, url } — matching the pattern of
|
||||||
|
linkedin_create_post.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
~/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
./summary.md
|
||||||
|
~/.claude/get-shit-done/references/checkpoints.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@src/tools.ts
|
||||||
|
@src/index.ts
|
||||||
|
@src/clients/linkedin.ts
|
||||||
|
|
||||||
|
**Established patterns to follow exactly:**
|
||||||
|
|
||||||
|
tools.ts tool definition (copy linkedin_create_post structure, lines ~256-274):
|
||||||
|
{ name, description, inputSchema: { type: 'object', properties: {...}, required: [...] } }
|
||||||
|
|
||||||
|
tools.ts import (line 7):
|
||||||
|
import { getProfile as getLinkedInProfile, createPost as createLinkedInPost, ... } from './clients/linkedin.js';
|
||||||
|
→ add createVideoPost to this import
|
||||||
|
|
||||||
|
tools.ts handler (lines ~778-787, inside handleToolCall switch):
|
||||||
|
case 'linkedin_upload_video':
|
||||||
|
result = await createLinkedInVideoPost({ ... }, customer);
|
||||||
|
break;
|
||||||
|
|
||||||
|
index.ts REST route (lines ~747-756, follow app.post('/api/linkedin/post') pattern):
|
||||||
|
app.post('/api/linkedin/video', requireAuth, async (req, res) => { ... })
|
||||||
|
→ calls handleToolCall('linkedin_upload_video', { video_url, text, visibility, account })
|
||||||
|
|
||||||
|
**Tool input schema fields:**
|
||||||
|
video_url: { type: 'string', description: 'Publicly accessible URL of the video file to upload' } — required
|
||||||
|
text: { type: 'string', description: 'Post caption / commentary text' } — required
|
||||||
|
visibility: { type: 'string', enum: ['PUBLIC','CONNECTIONS'], description: 'Post visibility. Default: PUBLIC' }
|
||||||
|
account: { type: 'string', description: 'Which LinkedIn account to use (default: "default")' }
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Register linkedin_upload_video MCP tool in tools.ts</name>
|
||||||
|
<files>src/tools.ts</files>
|
||||||
|
<action>
|
||||||
|
1. Add `createVideoPost as createLinkedInVideoPost` to the existing linkedin import on line 7.
|
||||||
|
Keep all other imports on that line unchanged.
|
||||||
|
|
||||||
|
2. In the `tools` array, insert a new tool definition immediately after the
|
||||||
|
`linkedin_create_post` entry (around line 274). Use this exact structure:
|
||||||
|
{
|
||||||
|
name: 'linkedin_upload_video',
|
||||||
|
description: 'Upload a video and create a LinkedIn post. Accepts a publicly accessible video URL, downloads it server-side, uploads it to LinkedIn via the Videos API, and publishes the post. Video must be publicly reachable (not localhost or private network). Large videos may take 30–90 seconds.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
video_url: { type: 'string', description: 'Publicly accessible URL of the MP4 video to upload' },
|
||||||
|
text: { type: 'string', description: 'Post caption / commentary text' },
|
||||||
|
visibility: { type: 'string', enum: ['PUBLIC', 'CONNECTIONS'], description: 'Post visibility. Default: PUBLIC' },
|
||||||
|
account: { type: 'string', description: 'Which LinkedIn account to use (default: "default")' },
|
||||||
|
},
|
||||||
|
required: ['video_url', 'text'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
3. In handleToolCall switch, add the handler case immediately after 'linkedin_create_post':
|
||||||
|
case 'linkedin_upload_video':
|
||||||
|
result = await createLinkedInVideoPost(
|
||||||
|
{
|
||||||
|
video_url: args.video_url as string,
|
||||||
|
text: args.text as string,
|
||||||
|
visibility: (args.visibility as 'PUBLIC' | 'CONNECTIONS') ?? 'PUBLIC',
|
||||||
|
account: args.account as string | undefined,
|
||||||
|
},
|
||||||
|
customer
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
</action>
|
||||||
|
<verify>npx tsc --noEmit passes; grep -n 'linkedin_upload_video' src/tools.ts shows both the definition and the case handler</verify>
|
||||||
|
<done>Tool appears in tools array and switch statement, tsc clean</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Add POST /api/linkedin/video REST route in index.ts</name>
|
||||||
|
<files>src/index.ts</files>
|
||||||
|
<action>
|
||||||
|
Insert immediately after the `app.post('/api/linkedin/post', ...)` block (around line 756):
|
||||||
|
|
||||||
|
app.post('/api/linkedin/video', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { video_url, text, visibility, account } = req.body;
|
||||||
|
const result = await handleToolCall('linkedin_upload_video', { video_url, text, visibility, account });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Do NOT add input validation beyond what handleToolCall already does — the tool's
|
||||||
|
required fields check ('video_url', 'text') already throws if missing.
|
||||||
|
</action>
|
||||||
|
<verify>npx tsc --noEmit passes; grep -n '/api/linkedin/video' src/index.ts shows the new route</verify>
|
||||||
|
<done>Route registered, tsc clean, follows exact pattern of adjacent routes</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<what-built>
|
||||||
|
Full linkedin_upload_video tool: client (uploadVideo + createVideoPost), MCP tool definition,
|
||||||
|
REST route POST /api/linkedin/video. The server fetches the video from video_url, uploads
|
||||||
|
it to LinkedIn in 4MB chunks, waits up to 90s for LinkedIn to process it, then publishes
|
||||||
|
the post and returns { success, post_id, url }.
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Build: npm run build — must complete without errors
|
||||||
|
2. Start server: npm start (or node dist/index.js) in a separate terminal
|
||||||
|
3. Test the MCP tool via Claude by saying:
|
||||||
|
"Upload the SquareMCP LinkedIn video to LinkedIn with text [your caption]"
|
||||||
|
and providing the video_url pointing to a publicly accessible MP4
|
||||||
|
(or use the rendered out/squaremcp-linkedin.mp4 hosted somewhere accessible)
|
||||||
|
4. Confirm the response contains { success: true, post_id: "urn:li:share:...", url: "https://www.linkedin.com/feed/update/..." }
|
||||||
|
5. Visit the returned URL in a browser and confirm the video post appears with correct caption
|
||||||
|
6. Check the audit log (if Redis is running) shows the linkedin:createVideoPost entry
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" if the post appeared on LinkedIn with video, or describe the error to fix</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Before declaring plan complete:
|
||||||
|
- [ ] `npm run build` (full TypeScript build) succeeds with zero errors
|
||||||
|
- [ ] `grep -n 'linkedin_upload_video' src/tools.ts` returns two matches (definition + case)
|
||||||
|
- [ ] `grep -n '/api/linkedin/video' src/index.ts` returns one match
|
||||||
|
- [ ] `grep -n 'createLinkedInVideoPost' src/tools.ts` returns two matches (import + call)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- linkedin_upload_video appears in MCP tool list (visible to Claude at http://localhost:3000/mcp)
|
||||||
|
- POST /api/linkedin/video route returns { success, post_id, url } on valid input
|
||||||
|
- Error from missing video_url or text propagates as 500 with { error: "..." }
|
||||||
|
- Build is clean — no TypeScript errors in any file
|
||||||
|
- Phase complete: LinkedIn video upload fully wired from URL → LinkedIn post
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-linkedin-video-upload/01-02-SUMMARY.md`:
|
||||||
|
|
||||||
|
# Phase 01 Plan 02: Tool + Route Wiring Summary
|
||||||
|
|
||||||
|
**[one-liner of what shipped]**
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
## Files Created/Modified
|
||||||
|
## Decisions Made
|
||||||
|
## Issues Encountered
|
||||||
|
## Next Step
|
||||||
|
Phase complete — linkedin_upload_video tool is live.
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user