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
6.6 KiB
phase, plan, type, depends_on, files_modified
| phase | plan | type | depends_on | files_modified | |
|---|---|---|---|---|---|
| 01-linkedin-video-upload | 01 | execute |
|
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.
<execution_context> ~/.claude/get-shit-done/workflows/execute-plan.md ./summary.md </execution_context>
@src/clients/linkedin.tsEstablished patterns in this file:
resolveToken(args, customer)handles both multi-tenant (Redis/OAuth) and env-var authlinkedinRequest(endpoint, token, method, body)wraps fetch for/v2API calls- All exported functions accept
{ account?: string }and optionalCustomer createToolAudit(customer.id, 'linkedin:action')wraps calls for audit logging- Errors bubble as thrown
Errorinstances (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
uploadUrlfrom init response,Content-Type: application/octet-stream - ETag collected from each PUT response's
ETagheader - Finalize:
POST /rest/videos?action=finalizeUpload - Poll:
GET /rest/videos/{encodedVideoUrn}untilstatus === 'AVAILABLE' - Post:
POST /rest/posts(not/v2/ugcPosts) — post ID returned inx-restli-idresponse header - Chunk size: 4 MB (4,194,304 bytes); LinkedIn enforces min 2 MB except last chunk
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'. npx tsc --noEmit runs without errors after adding the function uploadVideo is exported-ready (used by Task 2), tsc clean
Task 2: Add createVideoPost() exported function src/clients/linkedin.ts 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:
- Resolve token with existing
resolveToken(args, customer). - Get profile with existing
getProfile(args, customer)to buildauthorUrn. - Call
uploadVideo(args.video_url, authorUrn, accessToken)→ videoUrn. - 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 }
- The POST /rest/posts returns 201; the post URN is in the
x-restli-idresponse header (not the JSON body). Read it withres.headers.get('x-restli-id'). - Wrap in createToolAudit (same pattern as createPost — log text.slice(0,100) + video_url).
- 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. npx tsc --noEmit passes; grep -n 'createVideoPost' src/clients/linkedin.ts shows the export createVideoPost is exported, tsc clean, audit logging included
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<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>
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