Skip to content

Marketing Jobs Seam

⭐ New in 1.3 β€” A single, deliberately narrow endpoint: POST /api/marketing-jobs/enqueue. It’s how an outside agent hands Ghost a video to post, without reaching into anything else.

Ghost’s job is phone hands: driving a real Android device over ADB. Deciding what to post, when, and with what caption is a different job β€” a content brain β€” and it lives in a separate process (for the reference deployment, that’s an external social-media-agent orchestrator).

The two talk through exactly one seam:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ External agent (content brain) β”‚ β”‚ Ghost (phone hands) β”‚
β”‚ β”‚ HTTP β”‚ β”‚
β”‚ β€’ Decides what/when to post β”‚ ──────▢ β”‚ POST /api/marketing-jobs/ β”‚
β”‚ β€’ Writes caption + hashtags β”‚ POST β”‚ enqueue β”‚
β”‚ β€’ Renders the video β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ β”‚ β–Ό β”‚
β”‚ β”‚ β”‚ job_queue (status=pending) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚
β”‚ β–Ό next scheduler tick β”‚
β”‚ bots/tiktok/upload.py worker β”‚
β”‚ β”‚ β”‚
β”‚ β–Ό β”‚
β”‚ Saves as DRAFT on the phone β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The endpoint lives in the public gitd/ namespace (gitd/routers/marketing_jobs.py) so an external caller can drive Ghost without depending on the premium plugin.

This is the whole safety model, and it is not configurable.

No matter what you send, the job is saved as a draft. The seam has no permission to live-publish.

In enqueue_marketing_job(), the config’s action is hard-overridden to "draft":

gitd/routers/marketing_jobs.py
config = {
"video": req.video_path,
"caption": req.caption,
"hashtags": req.hashtags,
"action": "draft", # ← forced, ignores req.action
}

If a caller explicitly passes action="publish", the request still succeeds β€” but the attempt is logged as a warning and the job is saved as a draft anyway:

if req.action and req.action.lower() != "draft":
logger.warning(
"marketing_jobs.enqueue: rejecting action=%r from external caller; "
"saving as draft instead", req.action,
)

Going from draft β†’ published is a separate, human-in-the-loop step. Live auto-publishing is intentionally not something an external agent can reach through this API.

POST /api/marketing-jobs/enqueue

FieldTypeRequiredNotes
video_pathstringβœ…Absolute path to the video on the machine running Ghost. Validated to exist.
phone_serialstringβœ…ADB serial of the phone to post from.
captionstringPost caption. Default "".
hashtagsstringHashtag string. Default "".
tts_textstringIf set, Ghost injects a text-to-speech voiceover into the video.
accountstringExpected active TikTok account on the phone (sanity check).
scheduled_atstring (ISO)Informational only β€” the job runs ASAP on the next scheduler tick, not at this time.
actionstringIgnored. Forced to "draft".

Validation is strict up front (gitd/routers/marketing_jobs.py):

  • video_path must be absolute β†’ 400 otherwise
  • video_path must exist on disk β†’ 400 otherwise
  • phone_serial must be non-empty β†’ 400 otherwise
{
"job_id": "ghost-job-42",
"estimated_post_at": "2026-07-04T18:00:00Z",
"action": "draft",
"phone_serial": "YOUR_DEVICE_SERIAL"
}

estimated_post_at echoes back your scheduled_at β€” it is not enforced. job_id is the queue id you can watch in the Scheduler view.

Under the hood the endpoint wraps _enqueue_job(...) with job_type="post", priority=2, trigger="marketing_agent", and a max_duration_s of 1800. The existing bots/tiktok/upload.py worker picks it up on the next scheduler tick.

Terminal window
curl -X POST http://localhost:5055/api/marketing-jobs/enqueue \
-H "Content-Type: application/json" \
-d '{
"video_path": "/home/me/renders/clip_042.mp4",
"phone_serial": "YOUR_DEVICE_SERIAL",
"caption": "Ghosts in the machine πŸ‘»",
"hashtags": "#android #automation",
"tts_text": "Meet Ghost, the open-source Android agent."
}'

Ghost queues the draft, the worker opens TikTok on the phone, uploads the video, fills the caption, and stops at the draft screen.

Use the seam when:

  • You have an external orchestrator that decides content and just needs Ghost to put it on a phone
  • You want a hard guarantee that automation can only ever draft, never publish
  • You’re integrating Ghost as the β€œdevice layer” under your own content pipeline

Don’t use the seam when:

  • You want to drive the phone interactively β€” use Agent Chat or the MCP Server instead
  • You need to publish automatically β€” by design, you can’t; publishing stays manual
  • The video isn’t already rendered to a local file β€” this seam takes a finished file path, not a render request
  • Scheduler β€” the job queue this endpoint feeds; watch ghost-job-* runs here
  • MCP Server β€” the richer, tool-based way external agents drive Ghost
  • ADB Device Control β€” the phone-hands layer that actually performs the upload