Install
openclaw skills install tilda-publisherPublish 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.
openclaw skills install tilda-publisherThis 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.
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.
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);
}
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:
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 || '',
});
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.
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.
| Variable | Description |
|---|---|
TILDA_EMAIL | Email address for tilda.cc account |
TILDA_PASSWORD | Password for tilda.cc account |
TILDA_PROJECT_NAME | Target project name (optional) |
Install Playwright before first use:
npx playwright install chromium
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
User triggers:
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)User triggers:
Inputs:
page_title — name of the existing pagenew_content — updated contentUser triggers:
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:
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}`);
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`);
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');
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');
}
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 };
// 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);
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.
| Problem | Cause | Fix |
|---|---|---|
| Login page not found | Tilda changed the URL | Check page.url() and update selectors |
| Buttons not clickable | Modal overlay blocking | Add waitForTimeout(1000) before click |
| Content not inserted | Editor not fully loaded | Increase waitForLoadState timeout |
| CAPTCHA on login | Too many login attempts | Use session caching to avoid repeated logins |
| Publish hangs | Network or server error | Add retry logic with 60s timeout |
This skill can be extended to support:
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.
Trigger self-healing on any of these errors:
TimeoutError: waiting for selectorError: No element foundElementNotInteractableErrorstrict mode violation (multiple elements matched instead of one)Do NOT trigger self-healing for:
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 };
}
After getting the DOM dump, look for the best matching element.
Replacement lookup rules:
| Broken selector | What to look for in the dump |
|---|---|
.js-add-block-btn | button with text "Add", "+" or data-action containing "add", "block" |
.js-publish | button with text "Publish", "Release" |
.js-page-settings | button with text "Settings" or gear icon |
.js-add-page | button 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(' ', '-'))
)
);
}
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;
}
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;
}
}
}
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;
}
Replace all direct page.click(selector) calls with:
// Instead of:
await page.click('.js-publish');
// Use:
await retryWithHealing(page, resolveSelector('.js-publish'), 'publish', 'click');
If the element is still not found after two attempts, the agent:
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.
tilda-healing-needed.log with the date, step name, and screenshot path.