Skip to main content

What’s a Website in Ensemble?

In Ensemble, websites are built from ensembles with HTTP triggers:
  • Each route = one ensemble file
  • Pages render HTML using templates
  • APIs return JSON data
  • Static files (robots.txt, sitemap.xml) are ensembles too
No framework boilerplate. Just YAML configuration + HTML templates.

Quick Example: Hello World Page

Create ensembles/pages/hello.yaml:
name: hello-page
trigger:
  - type: http
    path: /hello
    methods: [GET]
    public: true
    responses:
      html: {enabled: true}

flow:
  - operation: html
    config:
      template: |
        <!DOCTYPE html>
        <html>
        <head>
          <title>Hello World</title>
        </head>
        <body>
          <h1>Hello, World!</h1>
          <p>Welcome to Ensemble.</p>
        </body>
        </html>

output:
  format: html
  rawBody: ${html.output}
Run: pnpm run build && pnpm run dev Visit: http://localhost:8787/hello That’s it! You just created a web page.

Website Structure

Organize your site by feature:
ensembles/
├── pages/              # HTML pages
│   ├── home.yaml       # GET /
│   ├── about.yaml      # GET /about
│   └── blog-post.yaml  # GET /blog/:slug
├── api/                # JSON endpoints
│   ├── users.yaml      # GET /api/users/:id
│   └── search.yaml     # GET /api/search
└── static/             # Generated files
    ├── robots.yaml     # GET /robots.txt
    └── sitemap.yaml    # GET /sitemap.xml
Key principle: One file per route. Each file is an ensemble with trigger: {type: http}.

Example 1: Homepage with Dynamic Content

Let’s fetch blog posts from a database and display them. Create ensembles/pages/home.yaml:
name: homepage
trigger:
  - type: http
    path: /
    methods: [GET]
    public: true
    responses:
      html: {enabled: true}
    templateEngine: liquid

flow:
  # Fetch latest posts from database
  - agent: fetch-posts
    operation: data
    config:
      backend: d1
      binding: DB
      query: |
        SELECT title, slug, excerpt, published_at
        FROM posts
        WHERE published = 1
        ORDER BY published_at DESC
        LIMIT 3

  # Render HTML
  - operation: html
    config:
      template: |
        <!DOCTYPE html>
        <html>
        <head>
          <title>My Blog</title>
          <style>
            body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 20px; }
            .post { margin: 30px 0; padding: 20px; border: 1px solid #ddd; }
            .post h2 { margin: 0 0 10px 0; }
            .post a { text-decoration: none; color: #0066cc; }
          </style>
        </head>
        <body>
          <h1>Welcome to My Blog</h1>

          <div class="posts">
            {% for post in fetch-posts %}
              <div class="post">
                <h2><a href="/blog/{{ post.slug }}">{{ post.title }}</a></h2>
                <p>{{ post.excerpt }}</p>
                <small>{{ post.published_at }}</small>
              </div>
            {% endfor %}
          </div>
        </body>
        </html>
      data:
        fetch-posts: ${fetch-posts}

output:
  format: html
  rawBody: ${html.output}
Visit: http://localhost:8787/

Example 2: Dynamic Blog Post Page

Create ensembles/pages/blog-post.yaml:
name: blog-post
trigger:
  - type: http
    path: /blog/:slug
    methods: [GET]
    public: true
    responses:
      html: {enabled: true}
    templateEngine: liquid

flow:
  # Fetch post by slug
  - agent: fetch-post
    operation: data
    config:
      backend: d1
      binding: DB
      query: |
        SELECT title, content, published_at, author
        FROM posts
        WHERE slug = ? AND published = 1
      params: [${input.params.slug}]

  # Render HTML
  - operation: html
    config:
      template: |
        <!DOCTYPE html>
        <html>
        <head>
          <title>{{ fetch-post[0].title }}</title>
        </head>
        <body>
          <article>
            <h1>{{ fetch-post[0].title }}</h1>
            <p><em>By {{ fetch-post[0].author }} on {{ fetch-post[0].published_at }}</em></p>
            <div>{{ fetch-post[0].content }}</div>
          </article>
          <a href="/">← Back to home</a>
        </body>
        </html>
      data:
        fetch-post: ${fetch-post}

output:
  format: html
  rawBody: ${html.output}
Visit: http://localhost:8787/blog/my-first-post ★ Insight ───────────────────────────────────── The :slug in the path becomes available as ${input.params.slug}. This is how you build dynamic routes with path parameters. ─────────────────────────────────────────────────

Example 3: Contact Form (GET + POST)

Handle both displaying a form (GET) and processing submissions (POST) in one ensemble. Create ensembles/pages/contact.yaml:
name: contact-form
trigger:
  - type: http
    path: /contact
    methods: [GET, POST]
    public: true
    rateLimit:
      requests: 3
      window: 60  # 3 submissions per minute
    responses:
      html: {enabled: true}

flow:
  # Only validate on POST
  - agent: validate
    condition: ${metadata.method === 'POST'}
    operation: code
    config:
      handler: |
        const errors = []
        if (!input.email || !input.email.includes('@')) {
          errors.push('Valid email required')
        }
        if (!input.message || input.message.length < 10) {
          errors.push('Message must be at least 10 characters')
        }
        return {
          valid: errors.length === 0,
          errors
        }

  # Send email if valid
  - agent: send-email
    condition: ${validate.valid}
    operation: email
    config:
      provider: sendgrid
      to: [[email protected]]
      subject: "Contact form: ${input.name}"
      body: ${input.message}
      from: ${input.email}

  # Render HTML (different for GET vs POST)
  - operation: html
    config:
      template: |
        <!DOCTYPE html>
        <html>
        <head>
          <title>Contact Us</title>
          <style>
            body { font-family: system-ui; max-width: 600px; margin: 50px auto; }
            input, textarea { width: 100%; padding: 10px; margin: 10px 0; }
            button { padding: 10px 20px; background: #0066cc; color: white; border: none; }
            .error { color: red; }
            .success { color: green; padding: 20px; border: 2px solid green; }
          </style>
        </head>
        <body>
          <h1>Contact Us</h1>

          {% if metadata.method == 'GET' %}
            <form method="POST">
              <input name="name" placeholder="Your name" required>
              <input name="email" type="email" placeholder="Your email" required>
              <textarea name="message" placeholder="Your message" rows="5" required></textarea>
              <button type="submit">Send Message</button>
            </form>
          {% elsif validate.valid %}
            <div class="success">
              <h2>Thank you!</h2>
              <p>We received your message and will respond soon.</p>
              <a href="/">← Back to home</a>
            </div>
          {% else %}
            <div class="error">
              <h2>Please fix these errors:</h2>
              <ul>
                {% for error in validate.errors %}
                  <li>{{ error }}</li>
                {% endfor %}
              </ul>
              <a href="/contact">← Try again</a>
            </div>
          {% endif %}
        </body>
        </html>

output:
  format: html
  rawBody: ${html.output}
★ Insight ───────────────────────────────────── One ensemble handles both GET (show form) and POST (submit). Use ${metadata.method} to check which HTTP method was used. Add rate limiting to prevent spam! ─────────────────────────────────────────────────

Example 4: JSON API Endpoint

Not everything needs to be HTML. Create ensembles/api/users.yaml:
name: users-api
trigger:
  - type: http
    path: /api/users/:id
    methods: [GET]
    auth:
      type: bearer
      secret: ${env.API_KEY}
    responses:
      json: {enabled: true}

flow:
  - agent: fetch-user
    operation: data
    config:
      backend: d1
      binding: DB
      query: "SELECT id, name, email, created_at FROM users WHERE id = ?"
      params: [${input.params.id}]

output:
  user: ${fetch-user[0]}
  found: ${fetch-user.length > 0}
Test:
curl -H "Authorization: Bearer YOUR_API_KEY" \
  http://localhost:8787/api/users/123
Returns:
{
  "user": {
    "id": 123,
    "name": "Alice",
    "email": "[email protected]",
    "created_at": "2024-01-15"
  },
  "found": true
}

Example 5: Static Files (robots.txt, sitemap.xml)

Even “static” files are ensembles - but they can be dynamic!

robots.txt

Create ensembles/static/robots.yaml:
name: robots-txt
trigger:
  - type: http
    path: /robots.txt
    methods: [GET]
    public: true

flow:
  - operation: code
    config:
      handler: |
        return {
          output: `User-agent: *
Allow: /
Disallow: /admin/

Sitemap: https://yourdomain.com/sitemap.xml`
        }

output:
  format: text
  rawBody: ${code.output}

sitemap.xml (Dynamic from Database)

Create ensembles/static/sitemap.yaml:
name: sitemap-xml
trigger:
  - type: http
    path: /sitemap.xml
    methods: [GET]
    public: true
    cache:
      enabled: true
      ttl: 3600  # Cache for 1 hour

flow:
  # Fetch all published posts
  - agent: fetch-posts
    operation: data
    config:
      backend: d1
      binding: DB
      query: "SELECT slug, updated_at FROM posts WHERE published = 1"

  # Generate XML
  - operation: code
    config:
      handler: |
        const posts = input.fetchPosts || []

        const urls = posts.map(post =>
          `  <url>
            <loc>https://yourdomain.com/blog/${post.slug}</loc>
            <lastmod>${post.updated_at}</lastmod>
          </url>`
        ).join('\n')

        return {
          output: `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://yourdomain.com/</loc>
    <priority>1.0</priority>
  </url>
${urls}
</urlset>`
        }

output:
  format: xml
  rawBody: ${code.output}
★ Insight ───────────────────────────────────── Your sitemap is generated dynamically from the database! As you add blog posts, they automatically appear in the sitemap. Add caching to avoid querying the database on every request. ─────────────────────────────────────────────────

Template Engines

Ensemble supports three template engines:
templateEngine: liquid

template: |
  <h1>{{ title }}</h1>
  {% for item in items %}
    <p>{{ item.name }}</p>
  {% endfor %}
  {% if user.admin %}
    <a href="/admin">Admin Panel</a>
  {% endif %}

2. Handlebars

templateEngine: handlebars

template: |
  <h1>{{title}}</h1>
  {{#each items}}
    <p>{{name}}</p>
  {{/each}}
  {{#if user.admin}}
    <a href="/admin">Admin Panel</a>
  {{/if}}

3. Simple (String Interpolation)

templateEngine: simple

template: |
  <h1>{{title}}</h1>
  <p>{{description}}</p>

Authentication

Protect routes with authentication:
trigger:
  - type: http
    path: /admin/dashboard
    methods: [GET]
    auth:
      type: bearer
      secret: ${env.ADMIN_TOKEN}
    responses:
      html: {enabled: true}
Or make routes public:
trigger:
  - type: http
    path: /
    methods: [GET]
    public: true  # No auth required
Default behavior: Routes require auth unless you set public: true.

CORS for APIs

Enable cross-origin requests:
trigger:
  - type: http
    path: /api/data
    methods: [GET, POST]
    public: true
    cors:
      origin: ["https://myapp.com", "https://staging.myapp.com"]
      credentials: true
      allowHeaders: ["Content-Type", "Authorization"]

Best Practices

1. Organize by Feature

✅ Good:
ensembles/
├── pages/blog/
│   ├── list.yaml
│   └── post.yaml
├── pages/user/
│   ├── profile.yaml
│   └── settings.yaml
└── api/
    └── search.yaml
❌ Bad:
ensembles/
├── all-pages.yaml      # Too big!
└── all-apis.yaml       # Too big!

2. Use Path Parameters

✅ Good:
path: /blog/:slug
path: /users/:userId/posts/:postId
❌ Bad:
path: /blog
# Then manually parse query params

3. Add Rate Limiting to Forms

trigger:
  - type: http
    path: /contact
    methods: [POST]
    rateLimit:
      requests: 3
      window: 60  # 3 per minute

4. Cache Expensive Operations

trigger:
  - type: http
    path: /sitemap.xml
    cache:
      enabled: true
      ttl: 3600  # 1 hour

5. Validate Input

flow:
  - agent: validate
    operation: code
    config:
      handler: |
        const errors = []
        if (!input.email?.includes('@')) errors.push('Invalid email')
        if (!input.message?.trim()) errors.push('Message required')
        return { valid: errors.length === 0, errors }

Testing Your Website

Create tests/pages.test.ts:
import { describe, it, expect } from 'vitest'
import { Executor } from '@ensemble-edge/conductor'
import { stringify } from 'yaml'
import homePage from '../ensembles/pages/home.yaml'

describe('Homepage', () => {
  it('should render HTML', async () => {
    const env = { DB: mockDB } as Env
    const ctx = {} as ExecutionContext

    const executor = new Executor({ env, ctx })
    const result = await executor.executeFromYAML(
      stringify(homePage),
      {}
    )

    expect(result.success).toBe(true)
    expect(result.value.output.html).toContain('<h1>')
  })
})
Run: pnpm test

Deployment

Deploy to Cloudflare Workers:
# Build
pnpm run build

# Deploy
pnpm run deploy
Your website is now live on Cloudflare’s global edge network!

What You Built

In this guide, you created:
  • ✅ Homepage with dynamic database content
  • ✅ Blog post pages with URL parameters
  • ✅ Contact form with validation and email
  • ✅ JSON API with authentication
  • ✅ Dynamic sitemap.xml from database
  • ✅ Static robots.txt file
All using simple YAML configuration and HTML templates. No framework boilerplate!

Next Steps

Troubleshooting

Problem: Visiting /hello returns 404Fixes:
  1. Rebuild: pnpm run build (ensembles are discovered at build time)
  2. Check path in ensemble matches URL: path: /hello
  3. Ensure ensemble is in ensembles/ directory
Problem: Page shows {{ post.title }} literallyFixes:
  1. Set template engine: templateEngine: liquid
  2. Check data is passed: data: { post: ${fetch-post} }
  3. Verify variable names match template
Problem: POST request fails or does nothingFixes:
  1. Add POST to methods: methods: [GET, POST]
  2. Check condition uses correct metadata: ${metadata.method === 'POST'}
  3. Verify form action matches path: <form method="POST" action="/contact">
Problem: ${fetch-posts} is empty arrayFixes:
  1. Check binding matches wrangler.toml: binding: DB
  2. Verify database has data: wrangler d1 execute DB --command "SELECT * FROM posts"
  3. Run migrations: wrangler d1 migrations apply DB