diff --git a/extending_synthos.md b/extending_synthos.md new file mode 100644 index 0000000..81b0dae --- /dev/null +++ b/extending_synthos.md @@ -0,0 +1,350 @@ +# Extending SynthOS + +This guide explains how to use SynthOS as an npm dependency and build your own white-labeled product on top of it. + +## Installation + +```bash +npm install synthos +``` + +## Quick start + +Create your entry point (e.g. `src/index.ts`): + +```typescript +import { Customizer, createConfig, init, server } from 'synthos'; + +class MyApp extends Customizer { + get productName() { return 'MyApp'; } + get localFolder() { return '.myapp'; } +} + +const customizer = new MyApp(); + +async function main() { + const config = await createConfig(customizer.localFolder, {}, customizer); + await init(config); + server(config, customizer).listen(4242, () => { + console.log('MyApp running on http://localhost:4242'); + }); +} + +main(); +``` + +Run it: + +```bash +npx ts-node src/index.ts +# or compile with tsc and run the JS output +``` + +That's it. You now have a fully working instance with your own branding. + +--- + +## The Customizer class + +`Customizer` is the single configuration surface. Subclass it and override getters to change behavior. Every getter has a sensible default so you only override what you need. + +### Branding + +| Getter | Default | Purpose | +|--------|---------|---------| +| `productName` | `'SynthOS'` | Name used in LLM prompts, chat messages, and the brainstorm assistant. | +| `localFolder` | `'.synthos'` | Name of the data folder created in the user's working directory. | +| `tabsListRoute` | `'/pages'` | Route that outdated pages redirect to. | + +```typescript +class MyApp extends Customizer { + get productName() { return 'Acme Builder'; } + get localFolder() { return '.acme'; } +} +``` + +### Content folders + +Override these to supply your own default pages, themes, scripts, etc. Each getter returns an absolute path to a folder on disk. + +| Getter | Default location | Contents | +|--------|-----------------|----------| +| `requiredPagesFolder` | `required-pages/` | Built-in system pages (builder, settings). | +| `defaultPagesFolder` | `default-pages/` | Starter page templates copied on first init. | +| `defaultThemesFolder` | `default-themes/` | Theme CSS/JSON files. | +| `defaultScriptsFolder` | `default-scripts/` | Platform-specific terminal scripts. | +| `pageScriptsFolder` | `page-scripts/` | Versioned page runtime scripts (page-v2.js, etc.). | +| `serviceConnectorsFolder` | `service-connectors/` | Connector JSON definitions. | + +```typescript +import path from 'path'; + +class MyApp extends Customizer { + get defaultPagesFolder() { + return path.join(__dirname, '../my-pages'); + } + get defaultThemesFolder() { + return path.join(__dirname, '../my-themes'); + } +} +``` + +When you don't override a folder, SynthOS uses its own built-in assets from the npm package. + +### Feature flags + +SynthOS has built-in feature groups that can be toggled on or off: + +- `pages` — Page serving and transformation +- `api` — Core API routes (settings, images, completions) +- `data` — Per-page table storage +- `brainstorm` — Brainstorm chat endpoint +- `search` — Web search (Brave Search) +- `scripts` — User script execution +- `connectors` — REST API connector proxy +- `agents` — A2A and OpenClaw agent routes + +Disable groups you don't need: + +```typescript +const customizer = new MyApp(); +customizer.disable('agents', 'connectors', 'search'); +``` + +Re-enable later if needed: + +```typescript +customizer.enable('search'); +``` + +Check at runtime: + +```typescript +if (customizer.isEnabled('brainstorm')) { + // brainstorm is active +} +``` + +### Custom routes + +Add your own Express routes that the server will mount alongside the built-in ones: + +```typescript +customizer.addRoutes( + (config, app) => { + app.get('/api/my-endpoint', (req, res) => { + res.json({ hello: 'world' }); + }); + } +); +``` + +To make the LLM aware of your routes (so pages can call them), pass route hints: + +```typescript +customizer.addRoutes({ + installer: (config, app) => { + app.get('/api/weather/:city', async (req, res) => { + // ... fetch weather + res.json(result); + }); + }, + hints: `GET /api/weather/:city +description: Get current weather for a city +response: { temp: number, condition: string }` +}); +``` + +You can also add route hints without routes (useful if you mount routes elsewhere): + +```typescript +customizer.addRouteHints( + `POST /api/my-custom-action +description: Does something custom +request: { input: string } +response: { result: string }` +); +``` + +### Custom transform instructions + +Append additional instructions to the LLM prompt that transforms pages. These are added after the built-in instructions on every page transformation call: + +```typescript +customizer.addTransformInstructions( + 'Always include a footer with "Powered by Acme" at the bottom of the viewer panel.', + 'Never use red as a primary color.' +); +``` + +--- + +## Startup lifecycle + +The three steps to start a SynthOS-based server: + +```typescript +// 1. Create config — resolves folder paths, discovers required pages +const config = await createConfig( + customizer.localFolder, // data folder name (e.g. '.myapp') + { debug: false, debugPageUpdates: false }, + customizer // your Customizer subclass +); + +// 2. Init — creates the data folder, copies default pages/themes/scripts +// Returns true on first run, false if folder already exists. +const firstRun = await init(config); + +// 3. Start the Express server +const app = server(config, customizer); +app.listen(4242); +``` + +### Config options + +| Option | Type | Default | Purpose | +|--------|------|---------|---------| +| `debug` | `boolean` | `false` | Log every HTTP request with timing. | +| `debugPageUpdates` | `boolean` | `false` | Log full LLM input/output for page transformations. | + +--- + +## Full example + +A complete white-labeled app with custom pages, disabled features, and extra routes: + +```typescript +import path from 'path'; +import { Customizer, createConfig, init, server } from 'synthos'; + +class AcmeBuilder extends Customizer { + get productName() { return 'Acme Builder'; } + get localFolder() { return '.acme'; } + + get defaultPagesFolder() { + return path.join(__dirname, '../acme-pages'); + } + + get defaultThemesFolder() { + return path.join(__dirname, '../acme-themes'); + } +} + +async function main() { + const customizer = new AcmeBuilder(); + + // Disable features we don't need + customizer.disable('agents', 'connectors'); + + // Add a custom API endpoint (with LLM-visible hints) + customizer.addRoutes({ + installer: (config, app) => { + app.get('/api/company/info', (_req, res) => { + res.json({ name: 'Acme Corp', plan: 'enterprise' }); + }); + }, + hints: `GET /api/company/info +description: Returns company information +response: { name: string, plan: string }` + }); + + // Tell the LLM to always use Acme branding + customizer.addTransformInstructions( + 'All new pages should include "Acme Corp" in the header.' + ); + + const config = await createConfig(customizer.localFolder, { debug: true }, customizer); + await init(config); + + const port = process.env.PORT ? parseInt(process.env.PORT) : 4242; + server(config, customizer).listen(port, () => { + console.log(`Acme Builder running on http://localhost:${port}`); + }); +} + +main(); +``` + +--- + +## Project structure + +A typical extending project looks like this: + +``` +my-app/ + package.json + tsconfig.json + src/ + index.ts # Entry point (createConfig + init + server) + acme-pages/ # Custom default pages (optional) + dashboard.html + dashboard.json + acme-themes/ # Custom themes (optional) + acme-dark-v1.css + acme-dark-v1.json +``` + +Your `package.json` depends on `synthos`: + +```json +{ + "name": "acme-builder", + "dependencies": { + "synthos": "^0.8.0" + }, + "scripts": { + "start": "ts-node src/index.ts" + } +} +``` + +--- + +## What you don't need to touch + +These are handled internally and npm consumers won't encounter them: + +- **`synthos-cli.ts`** — The built-in CLI. You write your own entry point instead. +- **`migrations.ts`** — Legacy v1-to-v2 page migration. Only applies to pre-existing SynthOS installs. +- **`sshTunnelManager.ts`** — Internal temp file names. Not user-facing. + +--- + +## API reference + +### Exports from `synthos` + +| Export | Type | Purpose | +|--------|------|---------| +| `Customizer` | Class | Base class to subclass for configuration. | +| `RouteInstaller` | Type | `(config: SynthOSConfig, app: Application) => void` | +| `createConfig` | Function | Builds the config object from customizer + options. | +| `init` | Function | Initializes the data folder (pages, themes, scripts). | +| `server` | Function | Creates and returns the Express app. | +| `SynthOSConfig` | Interface | The resolved config object passed throughout the system. | + +### Customizer getters (override in subclass) + +| Getter | Returns | Default | +|--------|---------|---------| +| `productName` | `string` | `'SynthOS'` | +| `localFolder` | `string` | `'.synthos'` | +| `requiredPagesFolder` | `string` | Built-in required-pages | +| `defaultPagesFolder` | `string` | Built-in default-pages | +| `defaultThemesFolder` | `string` | Built-in default-themes | +| `defaultScriptsFolder` | `string` | Built-in default-scripts | +| `pageScriptsFolder` | `string` | Built-in page-scripts | +| `serviceConnectorsFolder` | `string` | Built-in service-connectors | +| `tabsListRoute` | `string` | `'/pages'` | + +### Customizer methods (call on instance) + +| Method | Purpose | +|--------|---------| +| `disable(...groups)` | Turn off feature groups. | +| `enable(...groups)` | Turn feature groups back on. | +| `isEnabled(group)` | Check if a group is active. | +| `addRoutes(...installers)` | Register custom Express routes. | +| `addRouteHints(...hints)` | Add LLM-visible API documentation. | +| `addTransformInstructions(...instructions)` | Append rules to the page transform prompt. | diff --git a/package.json b/package.json index 6e2ce41..1c81f54 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "page-scripts", "required-pages", "service-connectors", + "migration-rules", "tests" ] } diff --git a/src/agents/openclaw/gatewayManager.ts b/src/agents/openclaw/gatewayManager.ts index 351a99d..cdff07c 100644 --- a/src/agents/openclaw/gatewayManager.ts +++ b/src/agents/openclaw/gatewayManager.ts @@ -13,6 +13,7 @@ export interface GatewayConfig { enabled: boolean; role: 'operator'; scopes: string[]; + productName?: string; } interface PendingRequest { @@ -290,6 +291,7 @@ export async function connectAgent(agent: { url: string; token: string; sshTunnel?: { enabled: boolean; command: string; password: string }; + productName?: string; }): Promise { // Start SSH tunnel first if configured if (agent.sshTunnel?.enabled && agent.sshTunnel.command) { @@ -308,6 +310,7 @@ export async function connectAgent(agent: { enabled: true, role: 'operator', scopes: ['operator.read', 'operator.write', 'operator.approvals'], + productName: agent.productName, }; return connect(gwConfig); } @@ -502,7 +505,7 @@ function sendConnectRequest(conn: GatewayConnection): void { permissions: {}, auth: { token: conn.config.token }, locale: 'en-US', - userAgent: 'SynthOS/1.0', + userAgent: `${conn.config.productName ?? 'SynthOS'}/1.0`, }, }; diff --git a/src/customizer/Customizer.ts b/src/customizer/Customizer.ts index 471c991..32da5d6 100644 --- a/src/customizer/Customizer.ts +++ b/src/customizer/Customizer.ts @@ -50,12 +50,23 @@ export class Customizer { return path.join(__dirname, '../../service-connectors'); } + /** Folder containing default scripts copied on init */ + get defaultScriptsFolder(): string { + return path.join(__dirname, '../../default-scripts'); + } + /** Route path for the "browse all pages/tabs" listing page. * Override in a derived class to change the redirect target for outdated pages. */ get tabsListRoute(): string { return '/pages'; } + /** Product name used in LLM prompts and branding strings. + * Override in a derived class for white-labeling. */ + get productName(): string { + return 'SynthOS'; + } + // --- Feature group control --- // Built-in groups: 'pages', 'api', 'connectors', 'agents', // 'data', 'brainstorm', 'search', 'scripts' diff --git a/src/init.ts b/src/init.ts index 7c6ac73..0eb09de 100644 --- a/src/init.ts +++ b/src/init.ts @@ -32,7 +32,7 @@ export async function createConfig( pagesFolder: path.join(process.cwd(), pagesFolder), requiredPagesFolder, defaultPagesFolder: customizer?.defaultPagesFolder ?? path.join(__dirname, '../default-pages'), - defaultScriptsFolder: path.join(__dirname, '../default-scripts'), + defaultScriptsFolder: customizer?.defaultScriptsFolder ?? path.join(__dirname, '../default-scripts'), defaultThemesFolder: customizer?.defaultThemesFolder ?? path.join(__dirname, '../default-themes'), pageScriptsFolder: customizer?.pageScriptsFolder ?? path.join(__dirname, '../page-scripts'), serviceConnectorsFolder: customizer?.serviceConnectorsFolder ?? path.join(__dirname, '../service-connectors'), diff --git a/src/service/server.ts b/src/service/server.ts index 32889f4..6dca159 100644 --- a/src/service/server.ts +++ b/src/service/server.ts @@ -39,7 +39,7 @@ export function server(config: SynthOSConfig, customizer: Customizer = defaultCu if (customizer.isEnabled('connectors')) useConnectorRoutes(config, app); // Agent routes - if (customizer.isEnabled('agents')) useAgentRoutes(config, app); + if (customizer.isEnabled('agents')) useAgentRoutes(config, app, customizer); // Data routes if (customizer.isEnabled('data')) useDataRoutes(config, app); diff --git a/src/service/transformPage.ts b/src/service/transformPage.ts index 1aeac6d..b8d4cc2 100644 --- a/src/service/transformPage.ts +++ b/src/service/transformPage.ts @@ -29,6 +29,8 @@ export interface TransformPageArgs extends AgentArgs { routeHints?: string; /** Custom transform instructions from Customizer. */ customTransformInstructions?: string[]; + /** Product name for branding in prompts (defaults to 'SynthOS'). */ + productName?: string; } export type ChangeOp = @@ -129,8 +131,9 @@ export async function transformPage(args: TransformPageArgs): Promise\nThe user has configured these agents:\n\n${agentBlocks.join('\n\n')}\n\n${AGENT_API_REFERENCE}`; } + const productName = args.productName ?? 'SynthOS'; const routeHintsBlock = args.routeHints ?? serverAPIs; - const systemMessage = [currentPage, routeHintsBlock, serverScripts, connectorsBlock, agentsBlock, themeBlock, messageFormat].filter(s => s).join('\n\n'); + const systemMessage = [currentPage, routeHintsBlock, serverScripts, connectorsBlock, agentsBlock, themeBlock, getMessageFormat(productName)].filter(s => s).join('\n\n'); const system: SystemMessage = { role: 'system', content: systemMessage @@ -139,7 +142,7 @@ export async function transformPage(args: TransformPageArgs): Promise s.trim() !== '').join('\n'); + const instructions = [userInstr, modelInstr, getTransformInstr(productName), customInstr].filter(s => s.trim() !== '').join('\n'); const prompt: UserMessage = { role: 'user', content: `\n${message}\n\n\n${instructions}` @@ -617,19 +620,20 @@ export function parseChangeList(response: string): ChangeList { // Prompt constants // --------------------------------------------------------------------------- -const messageFormat = -` -

{SynthOS: | User:} {message contents}

-` +function getMessageFormat(productName: string): string { + return ` +

{${productName}: | User:} {message contents}

+`; +} -const transformInstr = -`Apply the users to the .viewerPanel of the by generating a list of changes in JSON format. +function getTransformInstr(productName: string): string { + return `Apply the users to the .viewerPanel of the by generating a list of changes in JSON format. Never remove any element that has a data-locked attribute. You may modfiy the inner text of a data-locked element or any of its unlocked child elements. -If the involves clearning the chat history, remove all .chat-message elements inside the #chatMessages container except for the first SynthOS: message. You may modify that message contents if requested. -If there's no add a SynthOS: message to the chat with aasking the user what they would like to do. -If there is a but the intent is unclear, add a User: message with the to the chat and add a SynthOS: message asking the user for clarification on their intent. -If there is a with clear intent, add a User: message with the to the chat and add a SynthOS: message explaining your change or answering their question. +If the involves clearning the chat history, remove all .chat-message elements inside the #chatMessages container except for the first ${productName}: message. You may modify that message contents if requested. +If there's no add a ${productName}: message to the chat with aasking the user what they would like to do. +If there is a but the intent is unclear, add a User: message with the to the chat and add a ${productName}: message asking the user for clarification on their intent. +If there is a with clear intent, add a User: message with the to the chat and add a ${productName}: message explaining your change or answering their question. If a is overly long, summarize the User: message. When updating the .viewerPanel you may alse add/remove/update style blocks to the header unless they're data-locked. Use inline styles if you need to modify the .viewerPanel itself. @@ -676,6 +680,7 @@ Return ONLY the JSON array. Example: { "op": "update", "nodeId": "5", "html": "

Hello world

" }, { "op": "insert", "parentId": "3", "position": "append", "html": "
New message
" } ]`; +} const AGENT_API_REFERENCE = `## Agent API diff --git a/src/service/useAgentRoutes.ts b/src/service/useAgentRoutes.ts index f0aee8f..d8c3696 100644 --- a/src/service/useAgentRoutes.ts +++ b/src/service/useAgentRoutes.ts @@ -14,8 +14,9 @@ import { getTunnelStatus, } from '../agents'; import { v4 as uuidv4 } from 'uuid'; +import { Customizer } from '../customizer'; -export function useAgentRoutes(config: SynthOSConfig, app: Application): void { +export function useAgentRoutes(config: SynthOSConfig, app: Application, customizer?: Customizer): void { /** Strip the token and sshTunnel.password fields, add connection/tunnel status for agent responses. */ function toClientAgent(agent: AgentConfig): Record { @@ -41,6 +42,7 @@ export function useAgentRoutes(config: SynthOSConfig, app: Application): void { url: agent.url, token: agent.token, sshTunnel: agent.sshTunnel, + productName: customizer?.productName, }) .then(() => console.log(`[Agents] Auto-connected OpenClaw agent "${agent.name}"`)) .catch(err => console.warn(`[Agents] Auto-connect failed for "${agent.name}": ${err instanceof Error ? err.message : err}`)); @@ -241,7 +243,7 @@ export function useAgentRoutes(config: SynthOSConfig, app: Application): void { return; } - await connectAgent({ id: agent.id, name: agent.name, url: agent.url, token: agent.token, sshTunnel: agent.sshTunnel }); + await connectAgent({ id: agent.id, name: agent.name, url: agent.url, token: agent.token, sshTunnel: agent.sshTunnel, productName: customizer?.productName }); const status = getAgentStatus(agent.id); res.json({ connected: status.connected, authenticated: status.authenticated }); } catch (err: unknown) { diff --git a/src/service/useApiRoutes.ts b/src/service/useApiRoutes.ts index d91a51e..2bbdc04 100644 --- a/src/service/useApiRoutes.ts +++ b/src/service/useApiRoutes.ts @@ -435,11 +435,12 @@ export function useApiRoutes(config: SynthOSConfig, app: Application, customizer const { context, messages } = req.body; const completePrompt = await createCompletePrompt(config.pagesFolder, 'chat'); + const productName = customizer?.productName ?? 'SynthOS'; const system: { role: 'system'; content: string } = { role: 'system', - content: `You are a creative brainstorming assistant for SynthOS, a tool that builds pages through conversation. -SynthOS is like a WIKI for vibe coding. Each page has a chat panel and a viewer panel. They are vibe coding what's displayed in that viewer panel. They can then save that as a page. -The user is brainstorming — exploring ideas before building. Be concise, creative, and collaborative. + content: `You are a creative brainstorming assistant for ${productName}, a tool that builds pages through conversation. +${productName} is like a WIKI for vibe coding. Each page has a chat panel and a viewer panel. They are vibe coding what's displayed in that viewer panel. They can then save that as a page. +The user is brainstorming — exploring ideas before building. Be concise, creative, and collaborative. They may say that they want to build an app or page that does XYZ but they're talking about what they expect to see in the viewer panel. The goal is to help them generate a prompt for the builder that captures their vision, along with suggestions for next steps. Suggest concrete approaches when you can, not complex visions for some ellaborate app. @@ -450,14 +451,14 @@ ${context} Look at the and if it's empty it's the start of a new idea. Simply greet them and ask them what they're thinking of building. Suggestions could be help me decide, etc. -If you see a conversation between SynthOS and the User. Asses what they're building and ask them what they'd like help with. Maybe offer a few good next steps. +If you see a conversation between ${productName} and the User. Asses what they're building and ask them what they'd like help with. Maybe offer a few good next steps. -SynthOS exposes table storage and chat completion api's that every page can use. If the user wants to store something or use AI, your prompt should suggest using table storage or make llm calls. +${productName} exposes table storage and chat completion api's that every page can use. If the user wants to store something or use AI, your prompt should suggest using table storage or make llm calls. You MUST return your response as a JSON object with exactly these fields: { "response": "Your conversational reply — explanations, options, suggestions. Markdown OK.", - "prompt": "A clean, actionable instruction ready to paste into SynthOS chat to build what was discussed. Update this each exchange to reflect the latest brainstorm state.", + "prompt": "A clean, actionable instruction ready to paste into ${productName} chat to build what was discussed. Update this each exchange to reflect the latest brainstorm state.", "suggestions": ["Short clickable option A", "Short clickable option B", "Short clickable option C"] } diff --git a/src/service/usePageRoutes.ts b/src/service/usePageRoutes.ts index 805e66c..18ecb21 100644 --- a/src/service/usePageRoutes.ts +++ b/src/service/usePageRoutes.ts @@ -307,7 +307,8 @@ export function usePageRoutes(config: SynthOSConfig, app: Application, customize const configuredAgents = settings.agents; const routeHints = customizer ? buildRouteHints(customizer) : undefined; const customTransformInstructions = customizer ? customizer.getTransformInstructions() : undefined; - const result = await transformPage({ pagesFolder, pageState, message, instructions, modelInstructions, completePrompt, themeInfo, configuredConnectors, configuredAgents, routeHints, customTransformInstructions }); + const productName = customizer?.productName; + const result = await transformPage({ pagesFolder, pageState, message, instructions, modelInstructions, completePrompt, themeInfo, configuredConnectors, configuredAgents, routeHints, customTransformInstructions, productName }); if (result.completed) { const { html, changeCount } = result.value!; if (config.debug) {