Back to guides
Plays

Airtable Page Generator

Introduction

This guide walks you through building an Airtable integration that connects directly to the Flint API. Once set up, your team manages Flint pages as rows in an Airtable table — filling in content fields, then clicking buttons to create drafts, check status, publish, and unpublish pages without ever leaving the spreadsheet.

This workflow is designed for marketing, content, and growth teams who prefer a familiar table-based interface over the Flint editor for high-volume page production. The finished result is a table where each row represents one page, with action buttons that trigger the Flint API and auto-populated status and link columns that reflect each page's current state.

What you will build: A "Flint Pages" Airtable table with input columns for page content, four button-triggered scripts (Create, Fetch Status, Publish, Unpublish), and output columns that capture the page's edit link, staging link, production URL, and current status.

Prerequisites

Before starting, make sure you have:

  • An Airtable account on any plan, including the free trial. This guide deliberately avoids Team-plan-only features so the integration works on every plan.
  • A Flint account with API access (Enterprise plan). Contact sales to enable API access for your organization.

You will also need three values ready before writing any scripts:

Flint API key — Generate this from the Team page inside the Flint app. Treat it like a password.

Flint site UUID — Found on the Team page or visible in the Flint app URL when viewing your site's settings.

Template page slug — The URL path of an existing Flint page that serves as the design blueprint for all generated pages (for example, /pages/template). This page must already exist in Flint before you run the Create script.

Important: The template page is mandatory. Every page the Create script generates is a clone of this template's structure. If the template page is missing or the slug is wrong, every creation request will fail.

Step 1 — Create the Data Table

The quickest way to build the table is to paste a prompt into Airtable's built-in AI assistant (Omni) and let it generate the schema for you. The table has three groups of columns:

  • Input columns — the content fields your team fills in: a Slug identifier field, plus one column per content placeholder your template page expects (page title, hero heading, CTA text, and so on).
  • Action columns — button fields that open the Scripting extension and trigger each script.
  • Output/tracking columns — Status (single select), Task ID, and URL fields that the scripts populate automatically.

Airtable's AI will create the button fields but their actions are configured in Step 5 after you add the scripts.

Prompt to paste into Airtable AI (Omni):

Create a new table called "Flint Pages" with the following fields, in this exact order:

  1. 1.Slug — single line text (primary field). A short, URL-friendly identifier for the page.
  2. 2.*(Add one field per content placeholder your template expects — for example: Page Title, Page Description, Hero Heading, Hero Subheadline, CTA Text, CTA URL. Use single line text for short values, long text for paragraphs, and the URL type for links.)*
  3. 3.Create Page — button field, labeled "Create"
  4. 4.Status — single select with these options and colors: Not Started (gray), In Progress (blue), Staged (teal), Published (green), Error (yellow). Default new records to Not Started.
  5. 5.Fetch Task Status — button field, labeled "Fetch"
  6. 6.Edit Link — URL
  7. 7.Staging Link — URL
  8. 8.Publish Page — button field, labeled "Publish"
  9. 9.Production URL — URL
  10. 10.Unpublish Page — button field, labeled "Unpublish"
  11. 11.Task ID — single line text

Add one example row with Status = Not Started so the structure is clear. After creating the table, hide the Task ID field from the default grid view.

Note: the four button fields will have their actions configured manually afterward — just create them as button fields with the labels given; don't worry about wiring their actions.

Step 2 — Define the Status Field

The Status column is the row's state tracker. The scripts read and write it to reflect exactly where each page is in its lifecycle. The five values and their meanings:

  • Not Started — The row is filled in and ready. No Flint task has been started yet.
  • In Progress — A Flint task is currently running. Wait for it to finish, then click Fetch.
  • Staged — The draft page is ready for review. Use the Staging Link to preview it.
  • Published — The page is live in production. The Production URL column holds the public link.
  • Error — A task failed. Run Fetch to see the error message, then check the Flint dashboard.

Critical: These names are case-sensitive and must match the Status column options exactly. If the names don't match, the scripts will throw a "no such option" error when they try to update the field. Set Not Started as the default value for new rows.

Step 3 — Install the Scripting Extension

The Scripting extension is an Airtable add-on that runs JavaScript blocks directly inside your base. It is the bridge between the table buttons and the Flint API.

To install it:

  1. 1.Open your base and click Extensions in the top-right toolbar.
  2. 2.Click Add an extension.
  3. 3.Search for Scripting and add it.

Once installed, you can add multiple script blocks inside the extension — one for each action (Create, Fetch, Publish, Unpublish). Each block has its own name and code editor.

Note on plans: The Scripting extension runs on all Airtable plans, including free. The "Run script" automation action — a different feature that triggers scripts automatically — is restricted to Team plans and is not used in this guide. Everything here works without a Team plan.

Step 4 — Configure the Scripts

Each script starts with a small configuration block at the top. Fill in your three prerequisite values before saving any script:

javascript
const FLINT_API_KEY = 'YOUR_FLINT_API_KEY';
const SITE_ID = 'YOUR_SITE_ID';
const TEMPLATE_SLUG = '/pages/template';

Replace each placeholder with the values you gathered in Step 1 (Prerequisites).

Security note: Your API key is visible to anyone with edit access to the Airtable base. Use a dedicated key you can rotate independently — do not reuse a key shared with other systems.

Important — use remoteFetchAsync, not fetch: The Scripting extension runs inside a sandboxed iframe. Standard fetch calls are blocked by CORS, so all four scripts use remoteFetchAsync instead. This is Airtable's built-in proxy that routes the request through Airtable's servers. If you see a CORS error in the script output, the script is using fetch — replace it with remoteFetchAsync.

Script 4a — Create

The Create script reads the row's content columns, assembles an explicit context string that instructs the Flint agent to use every value verbatim, and sends a request to create a new draft page.

On success it writes the returned Task ID to the row and sets Status to In Progress. Run Fetch after roughly 1 to 3 minutes to pick up the result.

javascript
const FLINT_API_KEY = 'YOUR_FLINT_API_KEY';
const SITE_ID = 'YOUR_SITE_ID';
const TEMPLATE_SLUG = '/pages/template';

const table = base.getTable('Flint Pages');
const record = await input.recordAsync('Pick a page to generate', table);

if (!record) {
    output.text('No record selected.');
} else {
    const get = (field) => record.getCellValueAsString(field);

    // Read your content columns here. Add one line per field in your table.
    const fields = {
        'Page Title':       get('Page Title'),
        'Page Description': get('Page Description'),
        'Hero Heading':     get('Hero Heading'),
        'Hero Subheadline': get('Hero Subheadline'),
        'CTA Text':         get('CTA Text'),
        'CTA URL':          get('CTA URL'),
    };

    // Build the page slug from the Slug field (cleaned as a safety net)
    const slug = '/pages/' + get('Slug').toLowerCase().replace(/[^a-z0-9]+/g, '-');

    // Structured, explicit context so the agent maps each value to the right slot
    const context = `
Use the following values EXACTLY as written. Do not rephrase, shorten, rewrite, or invent content. Place each value in the matching section of the template.

Page Title: "${fields['Page Title']}"
Page Description: "${fields['Page Description']}"
Hero Heading: "${fields['Hero Heading']}"
Hero Subheadline: "${fields['Hero Subheadline']}"
CTA Text: "${fields['CTA Text']}"
CTA URL: "${fields['CTA URL']}"
    `.trim();

    output.markdown(`**Creating draft:** \`${slug}\``);

    const response = await remoteFetchAsync('https://app.tryflint.com/api/v1/agent/tasks', {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${FLINT_API_KEY}`,
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            siteId: SITE_ID,
            command: 'generate_pages',
            templatePageSlug: TEMPLATE_SLUG,
            items: [{ targetPageSlug: slug, context: context }],
            publish: false
        })
    });

    const data = await response.json();

    if (response.ok) {
        await table.updateRecordAsync(record.id, {
            'Task ID': data.taskId,
            'Status': { name: 'In Progress' }
        });
        output.markdown(`Task created: \`${data.taskId}\``);
    } else {
        await table.updateRecordAsync(record.id, {
            'Status': { name: 'Error' }
        });
        output.markdown(`Failed: ${JSON.stringify(data)}`);
    }
}

Script 4b — Fetch Status

The Fetch script checks a task that was started by any of the other three scripts. It reads the Task ID from the row, calls the Flint API, and updates the row based on the result.

When the task is complete, it populates the Edit Link, Staging Link, and Production URL fields and sets Status to Staged or Published depending on whether the page is live. This same script handles status checks after Create, Publish, and Unpublish.

javascript
const FLINT_API_KEY = 'YOUR_FLINT_API_KEY';

const table = base.getTable('Flint Pages');
const record = await input.recordAsync('Pick a row to check status for', table);

if (!record) {
    output.text('No record selected.');
} else {
    const taskId = record.getCellValueAsString('Task ID');
    if (!taskId) {
        output.markdown('No Task ID on this row yet. Run Create first.');
    } else {
        const response = await remoteFetchAsync(`https://app.tryflint.com/api/v1/agent/tasks/${taskId}`, {
            headers: { 'Authorization': `Bearer ${FLINT_API_KEY}` }
        });
        const data = await response.json();
        output.markdown(`**Task status:** ${data.status}`);

        if (data.status === 'completed') {
            const page = data.output?.pagesCreated?.[0] || data.output?.pagesModified?.[0];
            if (page) {
                const isPublished = !!page.publishedUrl;
                await table.updateRecordAsync(record.id, {
                    'Status': { name: isPublished ? 'Published' : 'Staged' },
                    'Edit Link': page.editUrl,
                    'Staging Link': page.previewUrl,
                    'Production URL': page.publishedUrl || null
                });
                output.markdown(isPublished
                    ? `Published! Production URL saved.`
                    : `Draft ready. Edit Link and Staging Link saved.`);
            } else {
                output.markdown('Task completed but no page data was returned.');
            }
        } else if (data.status === 'failed') {
            await table.updateRecordAsync(record.id, {
                'Status': { name: 'Error' }
            });
            output.markdown(`Task failed: ${data.errorMessage || 'no error message provided'}`);
        } else {
            output.markdown(`Still ${data.status}. Wait a moment and run Fetch again.`);
        }
    }
}

Script 4c — Publish

The Publish script pushes a staged draft to production. Before sending the request it runs pre-flight checks: it confirms the row is in Staged state, has both a Staging Link and a Task ID, and that the previous task has fully completed on Flint's side. This last check prevents concurrent-write errors that occur when a new task starts before the previous one has released its lock.

On success it sets Status to In Progress. Run Fetch after roughly 30 to 60 seconds to confirm and capture the Production URL.

javascript
const FLINT_API_KEY = 'YOUR_FLINT_API_KEY';
const SITE_ID = 'YOUR_SITE_ID';

const table = base.getTable('Flint Pages');
const record = await input.recordAsync('Pick a row to publish', table);

if (!record) {
    output.text('No record selected.');
} else {
    const slug = '/pages/' + record.getCellValueAsString('Slug').toLowerCase().replace(/[^a-z0-9]+/g, '-');
    const status = record.getCellValueAsString('Status');
    const taskId = record.getCellValueAsString('Task ID');
    const stagingLink = record.getCellValueAsString('Staging Link');

    // Pre-flight checks
    if (status !== 'Staged') {
        output.markdown(`Cannot publish — Status is **${status || 'empty'}**, expected **Staged**.`);
        if (status === 'In Progress') {
            output.markdown(`The page is still being generated. Run Fetch first.`);
        } else if (status === 'Published') {
            output.markdown(`This page is already published.`);
        } else {
            output.markdown(`Create the page first using the Create script.`);
        }
    } else if (!stagingLink) {
        output.markdown(`No Staging Link on this row — the draft is not ready. Run Fetch.`);
    } else if (!taskId) {
        output.markdown(`No Task ID on this row.`);
    } else {
        // Verify the previous task is fully completed on Flint's side
        output.markdown(`Verifying previous task \`${taskId}\` is finished...`);
        const checkRes = await remoteFetchAsync(`https://app.tryflint.com/api/v1/agent/tasks/${taskId}`, {
            headers: { 'Authorization': `Bearer ${FLINT_API_KEY}` }
        });
        const checkData = await checkRes.json();

        if (checkData.status !== 'completed') {
            output.markdown(`Previous task is still **${checkData.status}**. Wait a moment and try again.`);
        } else {
            output.markdown(`**Publishing:** \`${slug}\``);

            const response = await remoteFetchAsync('https://app.tryflint.com/api/v1/agent/tasks', {
                method: 'POST',
                headers: {
                    'Authorization': `Bearer ${FLINT_API_KEY}`,
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    siteId: SITE_ID,
                    prompt: `Publish the existing page at ${slug} to production. Do not modify its content.`,
                    publish: true
                })
            });

            const data = await response.json();

            if (response.ok) {
                await table.updateRecordAsync(record.id, {
                    'Task ID': data.taskId,
                    'Status': { name: 'In Progress' }
                });
                output.markdown(`Publish task started: \`${data.taskId}\`\n\nRun Fetch in ~30-60 seconds to capture the Production URL.`);
            } else {
                await table.updateRecordAsync(record.id, {
                    'Status': { name: 'Error' }
                });
                output.markdown(`Failed: ${JSON.stringify(data)}`);
            }
        }
    }
}

Script 4d — Unpublish

The Unpublish script removes a published page from production while keeping the draft intact. It mirrors the Publish script's pre-flight logic but requires Published state. The Production URL is cleared immediately when the task starts; after Fetch confirms completion, Status returns to Staged.

javascript
const FLINT_API_KEY = 'YOUR_FLINT_API_KEY';
const SITE_ID = 'YOUR_SITE_ID';

const table = base.getTable('Flint Pages');
const record = await input.recordAsync('Pick a row to unpublish', table);

if (!record) {
    output.text('No record selected.');
} else {
    const slug = '/pages/' + record.getCellValueAsString('Slug').toLowerCase().replace(/[^a-z0-9]+/g, '-');
    const status = record.getCellValueAsString('Status');
    const taskId = record.getCellValueAsString('Task ID');

    // Pre-flight checks
    if (status !== 'Published') {
        output.markdown(`Cannot unpublish — Status is **${status || 'empty'}**, expected **Published**.`);
        if (status === 'Staged') {
            output.markdown(`This page is not published yet — nothing to unpublish.`);
        } else if (status === 'In Progress') {
            output.markdown(`A task is still running. Run Fetch first.`);
        }
    } else if (!taskId) {
        output.markdown(`No Task ID on this row.`);
    } else {
        // Verify the previous task is fully completed on Flint's side
        output.markdown(`Verifying previous task \`${taskId}\` is finished...`);
        const checkRes = await remoteFetchAsync(`https://app.tryflint.com/api/v1/agent/tasks/${taskId}`, {
            headers: { 'Authorization': `Bearer ${FLINT_API_KEY}` }
        });
        const checkData = await checkRes.json();

        if (checkData.status !== 'completed') {
            output.markdown(`Previous task is still **${checkData.status}**. Wait a moment and try again.`);
        } else {
            output.markdown(`**Unpublishing:** \`${slug}\``);

            const response = await remoteFetchAsync('https://app.tryflint.com/api/v1/agent/tasks', {
                method: 'POST',
                headers: {
                    'Authorization': `Bearer ${FLINT_API_KEY}`,
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    siteId: SITE_ID,
                    prompt: `Unpublish the page at ${slug}. Remove it from production but keep the draft intact so it can be republished later. Do not modify its content.`,
                    publish: true
                })
            });

            const data = await response.json();

            if (response.ok) {
                await table.updateRecordAsync(record.id, {
                    'Task ID': data.taskId,
                    'Status': { name: 'In Progress' },
                    'Production URL': null
                });
                output.markdown(`Unpublish task started: \`${data.taskId}\`\n\nRun Fetch in ~30-60 seconds to confirm.`);
            } else {
                await table.updateRecordAsync(record.id, {
                    'Status': { name: 'Error' }
                });
                output.markdown(`Failed: ${JSON.stringify(data)}`);
            }
        }
    }
}

Step 5 — Wire Up the Button Columns

Each button field can be configured to run a script directly with a single click using the Run script action.

To configure each button:

  1. 1.Click the field header for the button column (for example, Create Page).
  2. 2.Set the button action to Run script.
  3. 3.Select the matching script from the list (Create, Fetch, Publish, or Unpublish).
  4. 4.Repeat for Fetch Task Status, Publish Page, and Unpublish Page.

Once wired up, clicking a button on any row runs the corresponding script immediately. The script will prompt the user to confirm the correct row before taking action.

Step 6 — Generate Your First Page

Walk through the complete lifecycle once with a real row:

  1. 1.Fill in the input columns — Enter the Slug, Page Title, Hero Heading, and the rest of your content fields for the row.
  2. 2.Click Create — The Scripting panel opens. Select the Create script, click Run, and confirm the row. Watch Status move to In Progress.
  3. 3.Wait 1 to 3 minutes — Flint is generating the page in the background.
  4. 4.Click Fetch — Run the Fetch script on the same row. Status moves to Staged and the Edit Link and Staging Link columns are populated.
  5. 5.Review the draft — Open the Staging Link to preview the generated page. If the content needs adjustments, open the Edit Link to refine it in Flint.
  6. 6.Click Publish — Run the Publish script. Status moves back to In Progress.
  7. 7.Click Fetch again — After 30 to 60 seconds, run Fetch. Status moves to Published and the Production URL column is populated.

To take a page offline, click Unpublish, then Fetch to confirm. Status returns to Staged and the Production URL is cleared.

Writing Good Input Content

The quality of the generated page depends directly on the content in the input columns. A few recommendations:

  • Be specific and complete. Write the final copy you want on the page, not placeholders or notes to yourself. The agent uses the input values verbatim and does not improvise.
  • Match the template's structure. If the template has a hero heading that is one short sentence, give the agent one short sentence — not a paragraph.
  • Do not leave fields blank. Empty fields may cause the agent to invent content, pull defaults from the template, or leave sections blank. Fill every column for every row before running Create.
  • Use the CTA URL field for absolute URLs. Relative paths like /contact may be misinterpreted; write https://yoursite.com/contact instead.

Status Reference

StatusMeaningNext action
Not StartedRow is ready to be createdClick Create
In ProgressA Flint task is runningWait, then click Fetch
StagedDraft is ready for reviewReview via Staging Link; click Publish
PublishedPage is live in productionOpen Production URL; click Unpublish to take down
ErrorA task failedClick Fetch for details; check the Flint dashboard

Troubleshooting

Template page missing or wrong slug — Every creation request fails with a 404. Verify the template page exists in Flint and that the TEMPLATE_SLUG constant exactly matches its URL path, including the leading slash.

CORS error in the Scripting panel — The script is using standard fetch instead of remoteFetchAsync. Replace every fetch( call with remoteFetchAsync(.

Status field "no such option" error — The Status column options in Airtable do not match the names the scripts write. Check that the five option names (Not Started, In Progress, Staged, Published, Error) are spelled and capitalized exactly as shown, with no extra spaces.

"Another write operation is already in progress" — A prior Flint task has not released its lock yet. Wait 30 to 60 seconds and try the action again. The Publish and Unpublish scripts run a pre-flight check against this but a small window exists where the API may still be locked.

Generated headings or CTAs do not match the table — The agent paraphrased instead of copying values verbatim. Check that the context string in the Create script still contains the explicit "Use the following values EXACTLY as written" instruction. Restoring that line usually fixes the issue.

Buttons appear unresponsive — Close and reopen the Scripting extension panel, or refresh the Airtable base. The extension occasionally loses connection after a long idle period.

How It Works Under the Hood

The Scripting extension runs JavaScript blocks inside a sandboxed iframe within Airtable. Its remoteFetchAsync function proxies outbound HTTP requests through Airtable's servers, which is why it can reach external APIs that a plain browser fetch call cannot.

All four scripts call two Flint API endpoints:

  • POST /api/v1/agent/tasks — Creates a new background task. The Create script uses the generate_pages command with a templatePageSlug and an array of target slugs and context strings. The Publish and Unpublish scripts use the prompt command with a plain-language instruction.
  • GET /api/v1/agent/tasks/{taskId} — Returns the current state of a task: its status, any error message, and (when complete) the URLs for the created or modified pages.

All three actions (create, publish, unpublish) are asynchronous. Flint starts the task immediately and returns a task ID, but the actual page generation or publishing happens in the background over the following minutes. This is why Fetch is a separate manual step rather than an automatic completion: the scripts cannot block and wait without timing out, so the team member polls manually when they expect the task to be done.

Extending the Integration

The integration is designed to be adapted for different page types:

  • Different template pages — Change the TEMPLATE_SLUG constant and update the input columns and context string to match the new template's placeholders.
  • Different slug prefix — The Create, Publish, and Unpublish scripts all prefix the row's Slug field with /pages/. Change this prefix across all four scripts to match your site's URL structure.
  • More content fields — Add a column to the Airtable table, add a line to the fields object in the Create script, and add the corresponding line to the context string. The agent will pick it up automatically.
  • Bulk creation — Import a CSV of rows into Airtable using the built-in CSV import feature, then run Create on each row in sequence. There is no batch endpoint; each row triggers one task.
  • Automated status refresh — On Airtable plans that support scheduled automations, you can configure a timed automation to run the Fetch script on all In Progress rows at a set interval, reducing the need for manual polling.

Further Reading