Files
hermes-mcp/.planning/phases/01-linkedin-video-upload/01-01-PLAN.md
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

163 lines
6.6 KiB
Markdown

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