Tilda Publisher

Publish and manage articles on Tilda website builder via browser automation. Use this skill when the user wants to create a new article page, edit an existing one, or publish a draft on their Tilda site.

Audits

Pass

Install

openclaw skills install tilda-publisher

Tilda Publisher

Overview

This skill handles creating, editing, and publishing articles on the Tilda platform via browser automation (Playwright). Tilda does not provide a write API, so all operations are performed through the browser UI exactly as a human would.


Onboarding (first run)

This section is an instruction for the agent. Run it once, on the first time the user invokes this skill, if any required env variable is missing.

Step 1 — Check for missing variables

const missing = [];
if (!process.env.TILDA_EMAIL)    missing.push('TILDA_EMAIL');
if (!process.env.TILDA_PASSWORD) missing.push('TILDA_PASSWORD');

if (missing.length > 0) {
  startOnboarding(missing);
}

Step 2 — Ask the user (one question at a time)

1. Email:

"To publish on Tilda I need your account credentials. What email do you use on tilda.cc?"

2. Password:

"What is your Tilda account password? ⚠️ It will be stored locally in the workspace .env file — never sent anywhere."

3. Project name (optional):

"What is the name of the Tilda project where articles should be published? If you only have one project, just press Enter — I'll find it automatically."

4. Confirmation:

"Got it! Testing the connection to Tilda..."

Run a test login (no publishing) and report back:

  • ✅ "Connection successful. Skill is ready!"
  • ❌ "Login failed. Please double-check your email and password."

Step 3 — Save to .env

const fs = require('fs');
const path = require('path');

function saveToEnv(vars) {
  const envPath = path.join(process.cwd(), '.env');
  let existing = '';

  if (fs.existsSync(envPath)) {
    existing = fs.readFileSync(envPath, 'utf8');
  }

  const lines = existing.split('\n').filter(Boolean);

  for (const [key, value] of Object.entries(vars)) {
    const idx = lines.findIndex(l => l.startsWith(`${key}=`));
    const line = `${key}=${value}`;
    if (idx >= 0) {
      lines[idx] = line;
    } else {
      lines.push(line);
    }
  }

  fs.writeFileSync(envPath, lines.join('\n') + '\n');
  console.log('Settings saved to .env');
}

saveToEnv({
  TILDA_EMAIL: emailFromUser,
  TILDA_PASSWORD: passwordFromUser,
  TILDA_PROJECT_NAME: projectFromUser || '',
});

Step 4 — Install Playwright if missing

async function ensurePlaywright() {
  try {
    require('playwright');
    console.log('Playwright already installed');
  } catch (e) {
    console.log('Installing Playwright...');
    const { execSync } = require('child_process');
    execSync('npm install playwright', { stdio: 'inherit' });
    execSync('npx playwright install chromium', { stdio: 'inherit' });
    console.log('Playwright installed');
  }
}

Let the user know this may take 1-2 minutes.

Onboarding checklist

After completing onboarding, confirm each item:

✅ TILDA_EMAIL    — saved
✅ TILDA_PASSWORD — saved
✅ Project        — "Project name" (or "will detect automatically")
✅ Playwright     — installed
✅ Test login     — successful

🎉 tilda-publisher is ready!
Say "publish an article" and provide a title and content.

Environment variables

VariableDescription
TILDA_EMAILEmail address for tilda.cc account
TILDA_PASSWORDPassword for tilda.cc account
TILDA_PROJECT_NAMETarget project name (optional)

Install Playwright before first use:

npx playwright install chromium

How it works

Agent receives task
       ↓
Launches headless browser
       ↓
Logs in to tilda.cc
       ↓
Navigates to the target project
       ↓
Creates or edits a page
       ↓
Fills in content blocks
       ↓
Publishes the page
       ↓
Returns the live URL

Usage scenarios

Scenario 1 — Create and publish a new article

User triggers:

  • "publish an article on Tilda"
  • "create a new blog post on Tilda"
  • "add a post to my site"

Inputs:

  • title — article title (required)
  • content — article body in Markdown or plain text (required)
  • project_name — Tilda project name (optional if only one project)
  • cover_image_url — cover image URL (optional)
  • seo_description — meta description (optional)

Scenario 2 — Edit an existing page

User triggers:

  • "update the article [title] on Tilda"
  • "change the text on the page [title]"

Inputs:

  • page_title — name of the existing page
  • new_content — updated content

Scenario 3 — Publish a draft

User triggers:

  • "publish the draft [title]"
  • "release the page that was on hold"

Step-by-step instructions

Step 1 — Login

const { chromium } = require('playwright');

const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();

await page.goto('https://tilda.cc/login/');
await page.waitForLoadState('networkidle');

await page.fill('input[name="email"], input[type="email"]', process.env.TILDA_EMAIL);
await page.fill('input[name="password"], input[type="password"]', process.env.TILDA_PASSWORD);
await page.click('button[type="submit"], .js-login-btn, input[type="submit"]');

await page.waitForURL('**/dashboard/**', { timeout: 15000 });
console.log('Login successful');

Login error handling:

  • URL did not change → check credentials
  • CAPTCHA appeared → notify user, manual login required
  • 2FA prompt → ask user for the code

Step 2 — Select project

await page.goto('https://tilda.cc/projects/');
await page.waitForLoadState('networkidle');

if (projectName) {
  await page.locator(`.project-title:has-text("${projectName}")`).first().click();
} else {
  await page.locator('.project-item').first().click();
}

await page.waitForLoadState('networkidle');
const projectId = page.url().match(/\/edit\/(\d+)\//)?.[1];
console.log(`Project ID: ${projectId}`);

Step 3 — Create a new page

await page.click('.js-add-page, [data-action="addpage"], button:has-text("Add page")');
await page.waitForSelector('.modal, .popup, .dialog', { timeout: 5000 }).catch(() => {});

await page.fill('input[name="title"], .page-title-input', title);
await page.click('.js-create-page, button:has-text("Create")');
await page.waitForLoadState('networkidle');

console.log(`Page "${title}" created`);

Step 4 — Add content

await page.click('.js-edit-page, [data-action="editpage"], .page-edit-btn');
await page.waitForURL('**/page/**', { timeout: 10000 });
await page.waitForLoadState('networkidle');

// Add title block (ST category)
await page.click('.js-add-block-btn, .add-block-button, [data-action="addblock"]');
await page.waitForSelector('.blocks-catalog, .block-picker', { timeout: 5000 });
await page.click('[data-category="ST"], .category-ST');
await page.click('.block-item:first-child');
await page.waitForLoadState('networkidle');

const titleBlock = await page.locator('[contenteditable]').first();
await titleBlock.click();
await page.keyboard.press('Control+a');
await page.keyboard.type(title);

// Add text block (TX category)
await page.click('.js-add-block-btn, .add-block-button');
await page.waitForSelector('.blocks-catalog, .block-picker');
await page.click('[data-category="TX"], .category-TX');
await page.click('.block-item:first-child');
await page.waitForLoadState('networkidle');

const textBlock = await page.locator('[contenteditable]').last();
await textBlock.click();
await page.keyboard.press('Control+a');
await page.keyboard.type(content);

console.log('Content added');

Step 5 — SEO settings (optional)

if (seoDescription) {
  await page.click('.js-page-settings, [data-action="settings"], .settings-btn');
  await page.waitForSelector('.settings-panel, .page-settings-modal');
  await page.click('[data-tab="seo"], .tab-seo, button:has-text("SEO")');
  await page.fill('textarea[name="descr"], .seo-description', seoDescription);
  await page.click('.js-save-settings, button:has-text("Save")');
  await page.waitForLoadState('networkidle');
  console.log('SEO settings saved');
}

Step 6 — Publish

await page.click('.js-publish, [data-action="publish"], button:has-text("Publish")');

await page.waitForSelector('.success-message, .published-notification, .t-popup_show', {
  timeout: 30000
});

const publishedUrl = await page.evaluate(() => {
  const link = document.querySelector('.published-url a, .page-url, [data-page-url]');
  return link ? link.href || link.textContent : null;
});

console.log(`Published: ${publishedUrl}`);
await browser.close();

return { success: true, url: publishedUrl };

Full ready-to-run script

// tilda-publish.js
const { chromium } = require('playwright');

async function publishArticle({ title, content, projectName, seoDescription }) {
  const browser = await chromium.launch({ headless: true });
  const context = await browser.newContext({
    viewport: { width: 1280, height: 900 },
    userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
  });
  const page = await context.newPage();

  try {
    console.log('Logging in...');
    await page.goto('https://tilda.cc/login/');
    await page.waitForLoadState('networkidle');
    await page.fill('input[type="email"]', process.env.TILDA_EMAIL);
    await page.fill('input[type="password"]', process.env.TILDA_PASSWORD);
    await page.click('button[type="submit"]');
    await page.waitForURL('**/dashboard/**', { timeout: 15000 });

    console.log('Selecting project...');
    await page.goto('https://tilda.cc/projects/');
    await page.waitForLoadState('networkidle');

    if (projectName) {
      await page.locator(`text="${projectName}"`).first().click();
    } else {
      await page.locator('.project-item, .projects-list__item').first().click();
    }
    await page.waitForLoadState('networkidle');

    console.log('Creating page...');
    await page.click('[data-action="addpage"], .js-add-page');
    await page.waitForTimeout(1000);
    await page.fill('input[name="title"]', title);
    await page.keyboard.press('Enter');
    await page.waitForLoadState('networkidle');

    console.log('Opening editor...');
    await page.click('.js-edit-page, [data-action="editpage"]');
    await page.waitForURL('**/page/**');
    await page.waitForLoadState('networkidle');
    await page.waitForTimeout(2000);

    console.log('Adding content...');
    await page.click('.js-add-block-btn');
    await page.waitForSelector('.blocks-catalog');
    await page.click('[data-category="ST"]');
    await page.click('.block-item:first-child');
    await page.waitForTimeout(1000);

    const titleBlock = await page.locator('[contenteditable]').first();
    await titleBlock.click();
    await page.keyboard.selectAll();
    await page.keyboard.type(title);

    await page.click('.js-add-block-btn');
    await page.waitForSelector('.blocks-catalog');
    await page.click('[data-category="TX"]');
    await page.click('.block-item:first-child');
    await page.waitForTimeout(1000);

    const textBlock = await page.locator('[contenteditable]').last();
    await textBlock.click();
    await page.keyboard.selectAll();
    await page.keyboard.type(content);

    await page.keyboard.press('Control+S');
    await page.waitForTimeout(1500);

    if (seoDescription) {
      console.log('Setting SEO...');
      await page.click('.js-page-settings, [data-action="settings"]');
      await page.waitForSelector('.settings-panel');
      await page.click('[data-tab="seo"]');
      await page.fill('textarea[name="descr"]', seoDescription);
      await page.click('.js-save-settings');
      await page.waitForTimeout(1000);
    }

    console.log('Publishing...');
    await page.click('.js-publish, [data-action="publish"]');
    await page.waitForSelector('.success-message, .t-popup_show', { timeout: 30000 });
    await page.waitForTimeout(2000);

    const publishedUrl = await page.evaluate(() => {
      const el = document.querySelector('.published-url a, [data-page-url]');
      return el ? (el.href || el.textContent?.trim()) : null;
    });

    console.log(`Published: ${publishedUrl || 'URL not detected'}`);
    return { success: true, url: publishedUrl };

  } catch (error) {
    console.error('Error:', error.message);
    await page.screenshot({ path: 'tilda-error.png' });
    return { success: false, error: error.message };
  } finally {
    await browser.close();
  }
}

publishArticle({
  title: process.env.ARTICLE_TITLE || 'Test article',
  content: process.env.ARTICLE_CONTENT || 'Article body...',
  projectName: process.env.TILDA_PROJECT_NAME || null,
  seoDescription: process.env.SEO_DESCRIPTION || null,
}).then(console.log);

Session caching (optimization)

To avoid logging in on every run, save and reuse cookies:

// Save after first login
await context.storageState({ path: 'tilda-session.json' });

// Load on subsequent runs
const context = await browser.newContext({
  storageState: 'tilda-session.json'
});

Tilda sessions last approximately 7 days before re-authentication is needed.


Troubleshooting

ProblemCauseFix
Login page not foundTilda changed the URLCheck page.url() and update selectors
Buttons not clickableModal overlay blockingAdd waitForTimeout(1000) before click
Content not insertedEditor not fully loadedIncrease waitForLoadState timeout
CAPTCHA on loginToo many login attemptsUse session caching to avoid repeated logins
Publish hangsNetwork or server errorAdd retry logic with 60s timeout

Extensions

This skill can be extended to support:

  • Tilda Feeds — publishing via the built-in news feed editor
  • Scheduled publishing — setting a publish date
  • Cover images — uploading covers via file input
  • Categories and tags — blog post categorisation
  • Page duplication — cloning a template page for faster publishing

Self-Healing — Automatic selector recovery

Tilda periodically updates its UI. If the script fails because a selector is not found, the agent must not immediately report an error to the user. It must first run a full self-diagnosis cycle and attempt to fix itself.

When to trigger self-healing

Trigger self-healing on any of these errors:

  • TimeoutError: waiting for selector
  • Error: No element found
  • ElementNotInteractableError
  • strict mode violation (multiple elements matched instead of one)

Do NOT trigger self-healing for:

  • Network errors (no internet)
  • Auth errors (wrong password)
  • JavaScript errors inside Tilda (their bug, not ours)

Recovery algorithm (follow in strict order)

Stage 1 — Diagnose

async function diagnose(page, failedSelector, stepName) {
  console.log(`Self-healing triggered for step: ${stepName}`);
  console.log(`Broken selector: ${failedSelector}`);

  await page.screenshot({
    path: `debug-${stepName}-${Date.now()}.png`,
    fullPage: true
  });

  const clickableElements = await page.evaluate(() => {
    const elements = document.querySelectorAll('button, a, [role="button"], [data-action], [class*="js-"]');
    return Array.from(elements).map(el => ({
      tag: el.tagName,
      id: el.id,
      classes: el.className,
      dataAction: el.getAttribute('data-action'),
      text: el.innerText?.trim().slice(0, 50),
      visible: el.offsetParent !== null
    })).filter(el => el.visible);
  });

  const formElements = await page.evaluate(() => {
    return Array.from(document.querySelectorAll('input, textarea, [contenteditable]')).map(el => ({
      tag: el.tagName,
      type: el.type,
      name: el.name,
      id: el.id,
      classes: el.className,
      placeholder: el.placeholder,
      contenteditable: el.getAttribute('contenteditable')
    }));
  });

  return { clickableElements, formElements };
}

Stage 2 — Find a replacement

After getting the DOM dump, look for the best matching element.

Replacement lookup rules:

Broken selectorWhat to look for in the dump
.js-add-block-btnbutton with text "Add", "+" or data-action containing "add", "block"
.js-publishbutton with text "Publish", "Release"
.js-page-settingsbutton with text "Settings" or gear icon
.js-add-pagebutton with text "Add page", "New page"
[contenteditable]elements with contenteditable="true"
input[name="title"]input with placeholder containing "title", "name"
function findBestReplacement(elements, brokenSelector) {
  const semanticMap = {
    'publish':   ['publish', 'release', 'go live'],
    'add-block': ['add block', 'add', '+'],
    'settings':  ['settings', 'parameters', 'options'],
    'add-page':  ['add page', 'new page'],
    'save':      ['save', 'apply', 'confirm'],
  };

  const intent = Object.keys(semanticMap).find(key =>
    brokenSelector.toLowerCase().includes(key.replace('-', ''))
  );

  if (!intent) return null;

  const keywords = semanticMap[intent];

  return elements.find(el =>
    keywords.some(kw =>
      el.text?.toLowerCase().includes(kw) ||
      el.dataAction?.toLowerCase().includes(kw) ||
      el.classes?.toLowerCase().includes(kw.replace(' ', '-'))
    )
  );
}

Stage 3 — Build new selector

function buildSelector(element) {
  if (element.dataAction) return `[data-action="${element.dataAction}"]`;
  if (element.id)         return `#${element.id}`;
  const jsClass = element.classes?.split(' ').find(c => c.startsWith('js-'));
  if (jsClass)            return `.${jsClass}`;
  if (element.text)       return `${element.tag.toLowerCase()}:has-text("${element.text}")`;
  return null;
}

Stage 4 — Retry with new selector

async function retryWithHealing(page, originalSelector, stepName, action) {
  try {
    await page.click(originalSelector, { timeout: 5000 });
    return true;
  } catch (e) {
    console.log(`Selector failed: ${originalSelector}`);

    const { clickableElements, formElements } = await diagnose(page, originalSelector, stepName);

    const replacement = findBestReplacement(
      action === 'fill' ? formElements : clickableElements,
      originalSelector
    );

    if (!replacement) {
      console.log('No replacement found — manual fix required');
      return false;
    }

    const newSelector = buildSelector(replacement);
    console.log(`Replacement found: ${newSelector}`);

    try {
      if (action === 'click') await page.click(newSelector, { timeout: 5000 });
      if (action === 'fill')  await page.fill(newSelector, '');
      console.log(`Succeeded with new selector: ${newSelector}`);
      await saveLearnedSelector(originalSelector, newSelector);
      return true;
    } catch (e2) {
      console.log(`New selector also failed: ${newSelector}`);
      return false;
    }
  }
}

Stage 5 — Save learned selectors

const fs = require('fs');
const LEARNED_FILE = './tilda-learned-selectors.json';

async function saveLearnedSelector(broken, replacement) {
  let learned = {};
  if (fs.existsSync(LEARNED_FILE)) {
    learned = JSON.parse(fs.readFileSync(LEARNED_FILE, 'utf8'));
  }
  learned[broken] = {
    selector: replacement,
    learnedAt: new Date().toISOString()
  };
  fs.writeFileSync(LEARNED_FILE, JSON.stringify(learned, null, 2));
  console.log(`Saved: ${broken} → ${replacement}`);
}

function loadLearnedSelectors() {
  if (!fs.existsSync(LEARNED_FILE)) return {};
  return JSON.parse(fs.readFileSync(LEARNED_FILE, 'utf8'));
}

function resolveSelector(defaultSelector) {
  const learned = loadLearnedSelectors();
  return learned[defaultSelector]?.selector || defaultSelector;
}

Usage in the main script

Replace all direct page.click(selector) calls with:

// Instead of:
await page.click('.js-publish');

// Use:
await retryWithHealing(page, resolveSelector('.js-publish'), 'publish', 'click');

Escalation to the user when self-healing fails

If the element is still not found after two attempts, the agent:

  1. Takes a final full-page screenshot
  2. Reports clearly to the user:
Could not find the publish button on the page.
Tilda may have updated its interface.

Screenshot saved: debug-publish-1234567890.png

To fix this:
1. Open the screenshot and find the Publish button
2. Open DevTools → copy the selector
3. Tell me: "update the publish selector to [new selector]"

Or run with headless: false to see the browser live.
  1. Writes to tilda-healing-needed.log with the date, step name, and screenshot path.