TL;DR
This hands on tutorial shows how to connect Google Sheets, an image generator, and the Instagram Graph API using OpenClaw so a marketer can plan and publish social posts from a spreadsheet. The first run builds the data model in a sheet, the second run generates images and captions, and the third run publishes to Instagram with safeguards. The result is a repeatable workflow automation that your team can review and control. Expect copy pasteable YAML and JavaScript you can adapt in minutes.
What You Will Build
You will ship a minimal yet production ready pipeline that reads planned posts from Google Sheets, generates on brand images from a prompt, assembles a caption with hashtags, and publishes to Instagram on a schedule. The pipeline writes status and error messages back to the sheet so marketing and engineering can collaborate without extra tooling. Optional steps add Slack approvals, alt text generation for accessibility, and dry run mode for safe testing.
By the end you will have:
- A Google Sheet that acts as your single source of truth for queued posts.
- An OpenClaw playbook that runs on a cron and coordinates tasks.
- A small Node.js worker that calls an image API and the Instagram Graph API.
- Observability that records success or failure to the sheet so you can fix issues quickly.
If you want a quick overview of product capabilities first, scan the AI marketing automation features on our site at what ButterGrow does.
Prerequisites
- A Google account with access to Google Sheets and permission to share the sheet with a service account.
- An Instagram business account connected to a Facebook page with Content Publishing permissions enabled.
- A Facebook app configured for the Instagram Graph API plus a long lived page access token.
- OpenClaw access to run a scheduled playbook, or a ButterGrow account to run it as a managed workflow.
- Node.js 18 or later to run the worker tasks locally during setup.
For background on automating social channels with a browser controlled approach, see how to automate Instagram and LinkedIn with Chrome DevTools MCP. This tutorial focuses on API based publishing rather than browser control.
Architecture Overview
The pipeline follows a simple agentic workflow with clear inputs and outputs:
- Scheduler triggers the playbook every hour.
- Read rows from Google Sheets where status equals queued and publish_time is in the past or within the window.
- For each row, generate an image from the prompt if image_url is empty, otherwise reuse the existing image.
- Compose caption from the caption template and hashtags.
- Publish to Instagram via the Graph API and capture the returned media ID.
- Update the row with status posted or failed plus an error message.
This design separates orchestration from business logic. OpenClaw handles retries and backoff while the worker handles API calls. The sheet stays human readable and becomes your lightweight content calendar.
Set Up Google Sheets
Step 1Create the sheet
Create a new Google Sheet named Social Queue. Add these columns exactly, one per header cell in row 1: title, prompt, caption_template, hashtags, alt_text, image_url, publish_time, status, error. Use ISO 8601 timestamps for publish_time such as 2026-06-26T15:30:00Z.
Enter two sample rows with status set to queued. One row should have an empty image_url so the generator runs. The other should include an already hosted image to exercise the reuse path.
Step 2Service account and sharing
Create a Google Cloud project and enable the Sheets API. Create a service account, download the JSON key, and share the sheet with the service account email as an editor. Record the sheet ID from the URL and the range you plan to read, for example Sheet1!A2:I1000.
Store the following secrets for later:
GOOGLE_SHEETS_PRIVATE_KEYGOOGLE_SHEETS_CLIENT_EMAILSHEET_IDSHEET_RANGE
Configure Instagram Graph API
Step 3App setup and token
Create or reuse a Facebook app and connect your Instagram business account. Enable permissions for reading and creating content. Generate a long lived page access token and store it as IG_PAGE_TOKEN. Record the connected Instagram business account ID as IG_BUSINESS_ID.
Step 4Posting model
Instagram publishing is a two step process via the Graph API. First you create a media container with the image URL and caption. Then you publish that container to the feed. We will implement both steps and record the media ID so we can deduplicate retries.
Create the OpenClaw Playbook
We will use a cron trigger with environment variables for secrets. The playbook calls a Node.js module that performs the work and returns structured results to be written back to the sheet.
# file: playbooks/sheets_to_instagram.yaml
version: 1
name: sheets_to_instagram
triggers:
- cron: "0 * * * *" # run at the top of every hour
env:
SHEET_ID: "${SHEET_ID}"
SHEET_RANGE: "${SHEET_RANGE}"
GOOGLE_SHEETS_CLIENT_EMAIL: "${GOOGLE_SHEETS_CLIENT_EMAIL}"
GOOGLE_SHEETS_PRIVATE_KEY: "${GOOGLE_SHEETS_PRIVATE_KEY}"
IG_BUSINESS_ID: "${IG_BUSINESS_ID}"
IG_PAGE_TOKEN: "${IG_PAGE_TOKEN}"
IMAGE_API_KEY: "${IMAGE_API_KEY}"
DRY_RUN: "false"
tasks:
- id: fetch_and_publish
run: node workers/sheets_to_instagram.js
timeout: 300000
retries: 3
retry_backoff_ms: 2000
on_fail: capture_error
- id: capture_error
run: node workers/capture_error.js
when: failed(fetch_and_publish)
If you prefer a guided setup, the ButterGrow onboarding walks you through secrets and scheduling.
Implement the Worker
We will use googleapis for Sheets, node-fetch for HTTP, and plain functions for clarity. The worker reads all eligible rows, fans out work, and writes changes back in a batch to minimize API calls.
npm init -y
npm install googleapis node-fetch@3
mkdir -p workers
Create workers/sheets_to_instagram.js with the following content:
// workers/sheets_to_instagram.js
import fetch from "node-fetch";
import { google } from "googleapis";
const {
SHEET_ID,
SHEET_RANGE,
GOOGLE_SHEETS_CLIENT_EMAIL,
GOOGLE_SHEETS_PRIVATE_KEY,
IG_BUSINESS_ID,
IG_PAGE_TOKEN,
IMAGE_API_KEY,
DRY_RUN,
} = process.env;
function serviceAccountAuth() {
// Fix escaped newlines in CI
const key = GOOGLE_SHEETS_PRIVATE_KEY.replace(/\\n/g, "\n");
return new google.auth.JWT({
email: GOOGLE_SHEETS_CLIENT_EMAIL,
key,
scopes: ["https://www.googleapis.com/auth/spreadsheets"],
});
}
async function readRows(auth) {
const sheets = google.sheets({ version: "v4", auth });
const res = await sheets.spreadsheets.values.get({
spreadsheetId: SHEET_ID,
range: SHEET_RANGE,
});
const rows = res.data.values || [];
// Map headers manually since range starts at A2. Adjust if your sheet differs.
return rows
.map((r, i) => ({
idx: i + 2, // row number in the sheet
title: r[0] || "",
prompt: r[1] || "",
caption_template: r[2] || "",
hashtags: r[3] || "",
alt_text: r[4] || "",
image_url: r[5] || "",
publish_time: r[6] || "",
status: r[7] || "",
error: r[8] || "",
}))
.filter((r) => r.status.toLowerCase() === "queued");
}
function dueToPublish(iso) {
if (!iso) return false;
const when = new Date(iso).getTime();
const now = Date.now();
// Publish if scheduled time passed within the last hour
return when <= now && now - when < 60 * 60 * 1000;
}
async function generateImage(prompt) {
if (!prompt) return null;
// Example using a generic images endpoint. Replace with your provider.
const res = await fetch("https://api.example.com/v1/images", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${IMAGE_API_KEY}`,
},
body: JSON.stringify({ prompt, size: "1024x1024" }),
});
if (!res.ok) throw new Error(`Image API error ${res.status}`);
const data = await res.json();
return data.url; // a public https URL
}
function buildCaption(template, hashtags) {
const tags = hashtags ? `\n\n${hashtags}` : "";
return `${template}${tags}`.trim();
}
async function createMedia(imageUrl, caption) {
const endpoint = `https://graph.facebook.com/v18.0/${IG_BUSINESS_ID}/media`;
const url = `${endpoint}?image_url=${encodeURIComponent(imageUrl)}&caption=${encodeURIComponent(
caption
)}&access_token=${IG_PAGE_TOKEN}`;
const res = await fetch(url, { method: "POST" });
if (!res.ok) throw new Error(`IG create media failed ${res.status}`);
const data = await res.json();
return data.id; // creation_id
}
async function publishMedia(creationId) {
const endpoint = `https://graph.facebook.com/v18.0/${IG_BUSINESS_ID}/media_publish`;
const url = `${endpoint}?creation_id=${encodeURIComponent(
creationId
)}&access_token=${IG_PAGE_TOKEN}`;
const res = await fetch(url, { method: "POST" });
if (!res.ok) throw new Error(`IG publish failed ${res.status}`);
const data = await res.json();
return data.id; // media id
}
async function writeBack(auth, updates) {
if (!updates.length) return;
const sheets = google.sheets({ version: "v4", auth });
const data = updates.map((u) => ({
range: `Sheet1!F${u.idx}:I${u.idx}`,
values: [[u.image_url || "", u.publish_time || "", u.status, u.error || ""]],
}));
await sheets.spreadsheets.values.batchUpdate({
spreadsheetId: SHEET_ID,
requestBody: { data, valueInputOption: "RAW" },
});
}
async function main() {
const auth = await serviceAccountAuth().authorize();
const rows = await readRows(auth);
const updates = [];
for (const row of rows) {
try {
if (!dueToPublish(row.publish_time)) continue;
let imageUrl = row.image_url;
if (!imageUrl) {
imageUrl = await generateImage(row.prompt);
}
const caption = buildCaption(row.caption_template, row.hashtags);
if (String(DRY_RUN).toLowerCase() === "true") {
updates.push({ idx: row.idx, image_url: imageUrl, publish_time: row.publish_time, status: "previewed", error: "" });
continue;
}
const creationId = await createMedia(imageUrl, caption);
const mediaId = await publishMedia(creationId);
updates.push({ idx: row.idx, image_url: imageUrl, publish_time: row.publish_time, status: `posted:${mediaId}`, error: "" });
} catch (err) {
updates.push({ idx: row.idx, image_url: row.image_url, publish_time: row.publish_time, status: "failed", error: String(err.message || err) });
}
}
await writeBack(auth, updates);
console.log(JSON.stringify({ processed: updates.length }));
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
Optional error capture task:
// workers/capture_error.js
console.error("Task failed. See logs for details.");
process.exit(1);
Test Locally Before Scheduling
Step 5Prepare a .env file
Create .env.local with your IDs and tokens. Do not commit secrets.
export SHEET_ID="your_sheet_id"
export SHEET_RANGE="Sheet1!A2:I1000"
export GOOGLE_SHEETS_CLIENT_EMAIL="svc-account@project.iam.gserviceaccount.com"
export GOOGLE_SHEETS_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
export IG_BUSINESS_ID="1789xxxxxxxxx"
export IG_PAGE_TOKEN="EAAG..."
export IMAGE_API_KEY="sk-..."
export DRY_RUN="true"
node workers/sheets_to_instagram.js
Set one row to a past time so the worker picks it up. In dry run mode, the script writes previewed and an image URL to the sheet but does not publish to Instagram.
Step 6Flip to live mode
When you are satisfied with previews, set DRY_RUN=false and run again. Verify the post appears in your Instagram feed and the row status changes to posted:<media_id>. If a failure occurs, check the error column and logs, fix the input, and rerun. You can requeue a failed row by clearing the status.
Scheduling and Backoff
OpenClaw retries failed tasks with exponential backoff. You can adjust the retry count and backoff in the playbook. Keep scheduled runs frequent enough to meet your publishing windows without hammering external APIs. For most feeds, an hourly schedule is a good balance.
Here is a quick comparison of scheduling modes you might consider:
| Mode | Pros | Cons |
|---|---|---|
| Hourly cron | Simple to reason about. Works with sheet driven queues. | Posts within the last hour only. Requires publish window logic. |
| Per row schedule | Precise timing through a timestamp per row. | More logic to compute the next due job and handle time zones. |
| Event driven | Reacts instantly to a status change in the sheet. | Requires webhooks and additional infra to receive events. |
If you need a hosted option with guardrails, the ButterGrow product includes scheduling, retries, and observability. Explore answers to common questions if you want more detail on how secrets, limits, and pricing work.
Approvals, Accessibility, and Brand Safety
Step 7Add human approvals
For marketing teams that prefer approvals, add a task that sends a Slack message with the caption and image preview. A reviewer reacts with an emoji to approve or reject. Store the decision in the sheet and publish only approved rows.
Step 8Alt text for accessibility
You already have an alt_text column. If it is empty, call a vision to text API to generate a concise description and write it back before publishing. This improves accessibility and search within Instagram.
Step 9Brand safety and content filters
Before posting, run the caption and image through a moderation endpoint. Reject rows that violate your policy and record the reason. This reduces risk while still keeping the pipeline fast.
Observability and Auditing
Add simple metrics to your worker output such as processed, posted, failed, and average latency. Persist a short audit record per row including the media ID and the exact caption string that was sent. With this trail you can answer questions from legal or support without digging through logs.
Extending the Pipeline
Here are four useful evolutions you can add next:
- Thumbnail and story variants from the same prompt so you cover multiple surfaces.
- Multi language captions by running the template through a translation task per locale.
- UTM parameters in bio link pages that match each post title for better analytics.
- A weekly digest that emails upcoming posts to stakeholders for visibility.
If you want to compare API based publishing with browser controlled automation, you can read more from the ButterGrow blog about social channel automation and related pieces.
Security and Secret Management
Treat every token as a secret. Keep the Google service account key and the page token in your secret manager rather than environment files. Rotate on a schedule and scope the Facebook app permissions to the minimum needed. Only grant the service account editor access to the specific sheet used by this pipeline.
Troubleshooting
- 400 or 403 from the Graph API usually means an invalid token or missing permissions. Regenerate the long lived token and verify the Instagram business account is connected to the page in the app.
- 429 suggests rate limiting. Reduce schedule frequency, add jitter, and avoid bulk updates during the same window as other tools.
- Media publish succeeds but no image appears. Confirm the image URL is publicly reachable over HTTPS and that the file size and dimensions meet Instagram requirements.
- Sheet updates fail intermittently. Batch writes in a single
values.batchUpdatecall, which the worker already does.
Where ButterGrow Fits
Everything above runs on OpenClaw just fine. If you would rather avoid managing tokens, schedules, and rollbacks by hand, ButterGrow gives you hosted orchestration with audit logs, approvals, and instant previews. You can explore the features and then use the onboarding to get started.
Ready to turn your spreadsheet into a steady social feed without extra headcount? ButterGrow runs this exact pipeline with built in retries, previews, and change tracking. Start from the onboarding section at how to set it up and ship your first scheduled post today.
References
- Instagram Graph API content publishing - Official guide to creating and publishing media containers via the API.
- Google Sheets API reference - Official documentation for reading and writing values with service accounts.
- OpenAI Images API overview - Reference for generating images from prompts that you can swap into the generator task.
Frequently Asked Questions
What Google Sheets columns do I need to support captions, alt text, and hashtags in this pipeline?+
Create columns for title, prompt, caption template, hashtags, alt text, image_url, publish_time, status, and error. The pipeline reads prompt and caption template to generate assets, writes image_url after generation, and sets status to queued or posted.
How do I authenticate to the Instagram Graph API in a production setup for a business account?+
Use a Facebook app with the Instagram Basic Display and Instagram Content Publishing permissions, then obtain a long lived page access token tied to the connected Instagram business account. Store the token in your secret manager and rotate it on a schedule.
How should I handle rate limits and failures when publishing daily content to Instagram?+
Batch posts with a spaced schedule, back off on 429 or 5xx, and implement retries with jitter. Use a dead letter queue or an OpenClaw failure handler to move failed rows to a retry state and capture the Graph API error message in the error column.
Can I swap the image generator for Midjourney or a local Stable Diffusion runner without changing the rest of the pipeline?+
Yes. Abstract the image generator into a task that accepts a text prompt and returns a URL. Whether you call the OpenAI Images API, Stability, or a local Automatic1111, the downstream tasks only need the returned URL and optional seed metadata.
What is the safest way to run a dry run and approval step before content goes live?+
Add a dry_run boolean in your playbook and gate the publish task behind a review state. Send the caption and image preview to Slack or email for human approval, then flip the row to approved for publishing during the next run.
How can I deploy this quickly using ButterGrow or OpenClaw without writing a full backend?+
Use the provided OpenClaw playbook to orchestrate tasks and environment variables for secrets. Start from the ButterGrow onboarding flow and adapt the playbook to your sheet ID and tokens, then schedule it from the dashboard.
Ready to try ButterGrow?
See how ButterGrow can supercharge your growth with a quick demo.
Book a Demo