Files
garfieldheron 0b9d863b38 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
2026-05-11 12:14:31 -04:00

6.6 KiB

phase, plan, type, depends_on, files_modified
phase plan type depends_on files_modified
01-linkedin-video-upload 01 execute
src/clients/linkedin.ts
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.

<execution_context> ~/.claude/get-shit-done/workflows/execute-plan.md ./summary.md </execution_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
Task 1: Add linkedinRestRequest helper and uploadVideo() function src/clients/linkedin.ts 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'. 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:

  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. 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>
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