Skip to content
All articles

The Agent Content Pipeline, End to End: Brief In, Per-Account Posts Out, Clicks Back, With Every Shager Call in Order

A full agent pipeline on Shager in TypeScript: brief, media, per-account captions, draft, client approval, staggered scheduling, delivery checks and clicks.

9 min read

Diagram: brief, draft per account, client approval, staggered publishing and a clicks report per account.
Five fixed stages. The agent runs all of them; a person steps in only at approval.

An end-to-end agent content pipeline on Shager has eight stages: read the brief, prepare media at a public URL, resolve the target accounts, write one caption per account, create a draft with a tracked link, get approval, schedule with staggered dispatch, then verify delivery and pull clicks per account. The agent owns the repetitive stages; a human owns the brief and the approval. Every Shager step is one REST call or one MCP tool with the same name.

Below is the whole chain as a single TypeScript program against the REST API, with the reasoning for each stage. If you prefer to drive it from an agent client, the tool calls are identical.

What are the stages, and who owns each?

Stage Owner Shager call
1. Brief Human None
2. Media Agent or your asset pipeline upload_media_from_url
3. Targets Agent list_channels, list_groups
4. Captions Agent (your model) None
5. Draft Agent create_post
6. Approval Client request_approval
7. Schedule Agent with publish, or human schedule_post
8. Verify and measure Agent get_post, get_analytics

Stage 4 is the only one that needs a language model. Everything else is deterministic plumbing, and should be written as code rather than left to the model's improvisation. That split is the biggest reliability lesson in agent distribution: let the model write, let code orchestrate.

Stage 1: what goes in the brief?

A brief the pipeline can act on has eight fields. Anything less and the agent starts guessing.

type Brief = {
  workspace: string;          // "Cedar Clinics"
  group: string;              // "All clinics"
  message: string;            // the facts, written by a human
  mediaUrl: string;           // public URL of the finished asset
  media: { width: number; height: number; durationMs?: number };
  link: string;               // landing page to track
  campaign: string;           // "flu-shots-2026"
  publishAt: string;          // "2026-10-27T08:00:00-05:00"
};

publishAt carries an offset on purpose. Shager requires ISO 8601 with an offset for schedule_at, and writing the offset in the brief means nobody has to remember which timezone "8am" meant.

Bar chart: clicks on Shager's tracked links per account, showing which page brought traffic.
What the agent gets from get_analytics. (Illustration, not real data)

Setup: one helper for every call

const API = "https://app.shager.ai/api/v1";

class ShagerError extends Error {
  constructor(public status: number, public code: string, message: string) {
    super(`${status} ${code}: ${message}`);
  }
}

async function shager<T>(method: string, path: string, body?: unknown): Promise<T> {
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(`${API}${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${process.env.SHAGER_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    const json = await res.json();
    if (res.ok) return json.data as T;
    if (res.status === 429) {
      await new Promise((r) => setTimeout(r, 60_000));
      continue;
    }
    throw new ShagerError(res.status, json.error.code, json.error.message);
  }
  throw new Error("Rate limited three times in a row");
}

Only 429 is retried. A 403 or 422 is information, and retrying it just repeats the same refusal. The limit is 120 requests per minute per key, which this pipeline won't approach because one create_post covers every account.

Stage 2: media

Shager imports media from a URL, so whatever produced the asset (an editor, a render job, a generation tool) only needs to leave it somewhere publicly reachable.

const workspaces = await shager<Array<{ id: string; name: string }>>("GET", "/workspaces");
const ws = workspaces.find((w) => w.name === brief.workspace);
if (!ws) throw new Error(`No workspace named ${brief.workspace}`);

const media = await shager<{ id: string; kind: string }>("POST", `/workspaces/${ws.id}/media`, {
  url: brief.mediaUrl,
  width: brief.media.width,
  height: brief.media.height,
  duration_ms: brief.media.durationMs,
});

Width, height and duration matter for vertical video. With them, Shager validates Reels, Shorts and Stories against each network's rules before scheduling, so a clip that's too long for a format fails now with a readable reason instead of failing on one network at dispatch time.

Stage 3: resolve targets

type Channel = { id: string; network: string; name: string; status: string; formats: string[] };

const groups = await shager<Array<{ id: string; name: string; channel_ids: string[] }>>(
  "GET", `/workspaces/${ws.id}/groups`,
);
const group = groups.find((g) => g.name === brief.group);
if (!group) throw new Error(`No group named ${brief.group}`);

const channels = (await shager<Channel[]>("GET", `/workspaces/${ws.id}/channels`))
  .filter((c) => group.channel_ids.includes(c.id) && c.status === "ACTIVE");

Filtering to ACTIVE here lets you report disconnected accounts up front. A page whose token expired is a conversation with the client, not something to discover after the post fails.

Stage 4: one caption per account

This is where your model earns its place. The function below is deliberately a stub; plug in whichever model and SDK your team uses.

async function writeCaptions(brief: Brief, channels: Channel[]): Promise<Map<string, string>> {
  // Prompt your model with: the brief's facts, each channel's name and network,
  // and these rules: keep facts identical, vary the opening and structure,
  // use the local detail in the channel name, no raw URLs, network-appropriate length.
  // Return channel_id -> caption, and validate that every channel got one.
  throw new Error("connect your model here");
}

const captions = await writeCaptions(brief, channels);

Two rules belong in that prompt every time. Same facts everywhere, because a price or date that drifts between pages is a real error. And no raw links, because the link is attached separately so each account gets its own tracked version. Our guide on brand consistency across many accounts covers where variation helps and where it hurts.

Stage 5: create the draft

type PostView = {
  id: string;
  status: string;
  destinations: Array<{ channel_id: string; account: string; network: string; format: string; state: string; url: string | null; error: string | null }>;
};

const draft = await shager<PostView>("POST", `/workspaces/${ws.id}/posts`, {
  label: `${brief.campaign} ${brief.publishAt.slice(0, 10)}`,
  caption: brief.message,
  channel_ids: channels.map((c) => c.id),
  media_ids: [media.id],
  per_channel: channels.map((c) => ({ channel_id: c.id, caption: captions.get(c.id)! })),
  cta_url: brief.link,
  campaign: brief.campaign,
  first_comment: `Details: ${brief.link}`,
  stagger_seconds: [30, 90],
});

cta_url gives every account its own short link with utm_content identifying the account. first_comment is posted right after each post goes live. stagger_seconds spaces accounts on the same network by a random 30 to 90 seconds, which is also the default. The result is a DRAFT.

Stage 6: approval

const approval = await shager<{ approval_url: string; emailed: boolean }>(
  "POST", `/workspaces/${ws.id}/posts/${draft.id}/approval`,
  { reviewer_name: "Priya Shah", reviewer_email: "priya@cedarclinics.example", send_email: true },
);
console.log("Approval link:", approval.approval_url);

The reviewer opens the link and approves without logging in. The pipeline stops here and resumes later; approval takes hours, not milliseconds. Persist draft.id and pick it up in a second run that checks get_post for APPROVED. If you want to know why this checkpoint sits after drafting and not after publishing, client approval workflows makes the case.

Stage 7: schedule

const post = await shager<PostView>("GET", `/workspaces/${ws.id}/posts/${draft.id}`);
if (post.status !== "APPROVED") {
  console.log(`Not approved yet: ${post.status}`);
} else {
  try {
    await shager<PostView>("POST", `/workspaces/${ws.id}/posts/${draft.id}/schedule`, {
      schedule_at: brief.publishAt,
    });
  } catch (e) {
    if (e instanceof ShagerError && e.code === "rejected") {
      console.error("A network rule failed:", e.message); // fix input, do not loop
    } else throw e;
  }
}

This needs a key with the publish scope, granted by a human at https://app.shager.ai/dashboard/agents. Shager validates every destination against its network's rules here, so a 422 rejected arrives before anything is queued.

Stage 8: verify delivery and measure

After the publish time, check each destination. Delivery states are PENDING, QUEUED, PROCESSING, PUBLISHED, FAILED and SKIPPED.

const after = await shager<PostView>("GET", `/workspaces/${ws.id}/posts/${draft.id}`);
for (const d of after.destinations) {
  if (d.state === "FAILED" || d.state === "SKIPPED") console.warn(d.account, d.state, d.error);
}

const stats = await shager<{ total_clicks: number; by_account: Array<{ account: string; network: string; clicks: number }> }>(
  "GET", `/workspaces/${ws.id}/analytics?from=2026-10-27T00:00:00-05:00&to=2026-11-03T00:00:00-05:00`,
);
console.table(stats.by_account);

Clicks per account are the end of Shager's part and the start of the client's. Joining them to leads through utm_content in the CRM is covered in per-account lead attribution.

FAQ

Should an agent client run this, or a script?

Both work. A script is better for the fixed stages and anything on a timer. An agent client such as Claude Code is better when the brief is fuzzy and you want to iterate on captions interactively. Many teams use the agent for stage 4 and code for everything else.

What if the brief targets accounts across several clients?

Run the pipeline once per workspace. Workspaces separate clients deliberately: their accounts, approvals, links and analytics never mix, which is also what the client expects when they read their report.

Where do I find the exact field definitions?

In the OpenAPI document, which lists every operation, input and scope. The agents page has the same information in a shorter form for agents and their builders.

Next step

Run the pipeline once with a single test account and a draft you never schedule. Read the draft in Shager, check the per-account links, then widen to the full group. Once that holds, wrap the stages in a weekly routine so the pipeline runs on a schedule instead of on someone's memory.

Want to try it on your accounts?

Shager distributes, measures and gets client approval, all in one place.