---
title: "How We Built Playgrounds: Bringing Browser-Based SQL Back to Tinybird Forward"
excerpt: "A technical deep-dive into the architecture decisions behind Playgrounds: Redis storage, workspace sharing, split-screen panels, template parameters, and AI-assisted SQL with diff preview."
authors: "Rafa Moreno"
categories: "Engineering Excellence"
createdOn: "2026-02-04 00:00:00"
publishedOn: "2026-02-04 00:00:00"
status: "published"
---

"Can I just run a query somewhere?"

We heard this a lot after launching Forward. The CLI workflow is great for building data products — but sometimes you're not building anything. You're investigating. Prototyping. Checking if your assumptions about the data are actually true.

Classic had this. You could open a SQL editor and just... query. Forward didn't.

Playgrounds brings it back. But we didn't just port the old feature — we rebuilt it with a different architecture. This post explains the decisions behind it.

## The Architecture

Playgrounds has a multi-node model. Each playground contains multiple nodes, and each node contains a SQL query. Nodes can reference each other like CTEs, forming a pipeline.

```txt
┌─────────────────────────────────────────────────────────────────────┐
│                         Playgrounds                                 │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌─────────┐     ┌─────────────────┐     ┌────────────────────┐     │
│  │  Redis  │◀────│  Python API     │◀────│  React Frontend    │     │
│  │         │     │  /v0/playgrounds│     │                    │     │
│  └─────────┘     │  /v0/sql        │     │  ┌──────────────┐  │     │
│                  └─────────────────┘     │  │ CodeMirror   │  │     │
│                                          │  │ SQL Editor   │  │     │
│                                          │  └──────────────┘  │     │
│                                          │                    │     │
│                                          │  ┌──────────────┐  │     │
│                                          │  │ Split Screen │  │     │
│                                          │  │ Panel System │  │     │
│                                          │  └──────────────┘  │     │
│                                          └────────────────────┘     │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
```

Our app backend is in Python. The `Playground` model stores properties like `user_id`, `workspace_id`, `name`, `pipeline`, `shared_with`. The `pipeline` contains an ordered list of nodes, each with its own SQL.

The frontend is React with Next.js. A resizable panel group holds the main editor and results, with a split-screen panel system for inspecting datasource schemas while you write.

Two playground types exist: `"playground"` for SQL exploration, and `"exploration"` for AI-driven chat sessions. Same underlying model, different UI.

Why Redis? Playgrounds are ephemeral by nature. They don't need the transactional guarantees of PostgreSQL. They don't need complex querying. They need fast reads, fast writes, and simple key-value semantics. Redis with atomic transactions gives us exactly that — and it's already in our stack.

The RESTful API follows a standard pattern: `GET/POST /v0/playgrounds` for listing and creating, `GET/PUT/DELETE /v0/playgrounds/{id}` for individual operations. The API layer handles access control, validates workspace membership, and triggers notifications when playgrounds are shared.

{% html %}
<iframe width="560" height="315" src="https://www.youtube.com/embed/XE48EohbV_I" title="Playground with nodes" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
{% /html %}

## Five Design Decisions That Make It Work

### 1. The `@workspace` Sharing Model

**The problem:** Playgrounds need to be private by default — you don't want everyone seeing your half-baked queries. But you also need to share them with your team when you're ready.

We could have built a full permissions model: share with specific users, roles, groups. But that's complex. It requires a permissions lookup on every access. It requires UI for managing permissions. It requires explaining the model to users. And we wanted to ship fast.

**The solution:** A special marker in the `shared_with` array.

{% html %}
<iframe width="560" height="315" src="https://www.youtube.com/embed/kYSOShjxHak" title="Share button toggles workspace visibility" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
{% /html %}

```python
# playground.py

SHARING_SCOPE_WORKSPACE = "@workspace"

class Playground:
    # Properties: user_id, workspace_id, name, pipeline, shared_with, ...

    def is_shared_with_workspace(self) -> bool:
        """Check if shared with all workspace members."""
        return self.shared_with is not None and SHARING_SCOPE_WORKSPACE in self.shared_with

    def has_access(self, user_id: str, workspace_id: str) -> bool:
        if self.workspace_id != workspace_id:
            return False
        if self.is_owner(user_id):
            return True
        if self.is_shared_with_user(user_id):
            return True
        return self.is_shared_with_workspace()
```

The `shared_with` field is a simple array of strings. Most values are user IDs — specific people you've shared with. But `@workspace` is a special marker that means "everyone in this workspace has access."

The `@` prefix convention is intentional. User IDs are ULIDs, so they never start with `@`. This makes parsing unambiguous: if it starts with `@`, it's a scope marker. If it doesn't, it's a user ID. We can add more scopes later (`@team-engineering`?) without changing the data model.

This gave us a binary choice (private or workspace-wide) without building a full permissions system. The UI is a simple toggle. The API is simple. The mental model is simple. Room to extend later if needed.

**The edge case:** Non-owners updating shared playgrounds. If you can see a playground but don't own it, should you be able to edit it? We decided yes — you can edit the SQL, add nodes, run queries. But you can't change the `shared_with` field. Only the owner controls visibility. The API detects when a non-owner tries to modify `shared_with` and silently ignores that field while applying other changes.

### 2. The Split-Screen Panel System

**The problem:** When writing SQL, you often need to check a table schema. What columns does this datasource have? What's the type of that field? Is it `DateTime` or `DateTime64`?

Switching tabs breaks your flow. Pop-ups are annoying. Opening DevTools to check the API response works, but it's clunky. We wanted something that feels native to the editor experience.

**The solution:** A React context-based panel system. Click a table name in your SQL, and a side panel opens with the schema.

{% html %}
<iframe width="560" height="315" src="https://www.youtube.com/embed/jM8h-I3iFaU" title="Click any table name to inspect its schema" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
{% /html %}

We built a React context that any component can call: `openSplitScreen('dataSource', { name: 'my_table' })`. The panel renders on the right. Escape closes it.

Seven panel types cover different resources you might inspect:

- **DataSourcePanel**: Schema and sample data from a datasource
- **PipePanel**: Pipeline structure and node details
- **NodePanel**: Another node from the same playground
- **ServiceDataSourcePanel**: System tables like `tinybird.datasources_ops_log`
- **ResourcePanel**: Generic resource inspection
- **PlaygroundExplain**: Query execution plan with `EXPLAIN`

The CodeMirror editor detects clicks on syntax-highlighted table names. Each table type has a CSS class (`cm-datasource`, `cm-pipe`, `cm-node`), and we listen for clicks on those elements:

```tsx
// Each datasource type gets a CSS class for syntax highlighting
// Click handler detects these and opens the appropriate panel
const handleClick = (editor, event: MouseEvent) => {
  const element = event.target
  if (!(element instanceof HTMLElement)) return

  // Check if clicked element is a table name (datasource, pipe, or node)
  if (element.className.startsWith('cm-datasource')) {
    onTableClick?.(element.innerText)
  } else if (element.classList.contains('cm-pipe')) {
    onTableClick?.(element.innerText)
  }
}
```

Escape key closes the panel. The resizable handle lets you adjust the split. Clicking a different table replaces the current panel. It feels like the schema is always right there — because it is.

This pattern emerged from refactoring. We originally had split-screen logic scattered across components. Extracting it into a shared provider made the code cleaner and the behavior consistent across Playgrounds, Explorations, and Time Series.

{% html %}
<iframe width="560" height="315" src="https://www.youtube.com/embed/jiT0hcHi1Hc" title="Check Explain Query from a node" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
{% /html %}

### 3. Template Parameters Detection

**The problem:** Tinybird pipes support template parameters with `{{param}}` syntax. It's how you build parameterized API endpoints. Playgrounds should support the same syntax so you can prototype parameterized queries before deploying them as pipes.

We needed to detect parameters in the SQL, provide UI inputs to set values, and pass them to the `/v0/sql` endpoint.

**The solution:** Parse the SQL on the frontend, render inputs for each detected parameter, and include values in the API request.

{% html %}
<iframe width="560" height="315" src="https://www.youtube.com/embed/s8j9mW8a7rY" title="Parameters in Playgrounds" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
{% /html %}

Two ways to pass parameters:

1. **`template_parameters`**: A JSON object with predefined defaults from the parameter definitions
2. **Individual query params**: User-provided values that override defaults

The backend merges them and evaluates the template before executing the SQL.

The detection happens on the frontend when rendering the node. A regex finds `{{param_name}}` patterns in the SQL. For each unique parameter, we render a text input. Change the value, re-run the query. No need to edit the SQL itself.

The `%` prefix at the start of SQL enables Jinja templating mode, unlocking `{% if %}` conditionals and `{% for %}` loops. Without the prefix, only simple `{{param}}` substitution works. Most playground queries don't need full Jinja, so we default to the simpler mode.

### 4. A Custom CodeMirror Language for Tinybird SQL

**The problem:** Tinybird's query language is a hybrid: ClickHouse® SQL + Tornado-style Python templating + dynamic API parameters. Standard SQL editor modes don't understand this.

```tinybird
-- Layer 1: ClickHouse® SQL
SELECT city, count() as visits
FROM analytics_events

-- Layer 2: Tornado templating (control flow)
{\% if defined(start_date) %}
WHERE timestamp >= {{ DateTime(start_date, '2024-01-01') }}
{\% end %}

-- Layer 3: Dynamic parameters
{{ String(filter_by, 'all', required=True) }}
```

**The solution:** We built a custom CodeMirror language extension that understands all three layers.

The implementation splits into focused modules:

- **`tinybird-sql.ts`** — Core SQL dialect, completions, templating decorations
- **`tinybird-control.ts`** — Tornado control keywords (`if`, `for`, `set`, `end`)
- **`tinybird-functions.ts`** — 700+ ClickHouse® function signatures with docs
- **`tinybird-setup.tsx`** — Editor setup and keybindings
- **`QueryEditor.tsx`** — React component that assembles everything

The key innovation is **context-aware completion**. The editor detects which templating block you're in and provides different completions:

- **Inside `{{ }}`** → parameter types: `String()`, `Int()`, `DateTime()`, `column()`
- **Inside `{% %}`** → control keywords: `if`, `for`, `set`, `end`, `defined()`
- **Outside templating** → SQL completions: keywords, functions, table/column names

This works by scanning backwards from the cursor to find the most recent unclosed delimiter (`{{`, `{%`, or `{#`). If no unclosed block is found, we're in plain SQL.

The editor also fetches schema information dynamically. When you type `FROM events`, it calls `DESCRIBE` to get column info. Type `events.` and autocomplete shows only that table's columns with their types.

Each of the ClickHouse® functions includes inline documentation with snippets. Selecting `argMax` expands to `argMax(arg, val)` with tab stops for each argument.

### 5. AI-Assisted SQL with Diff Preview

**The problem:** AI can generate SQL. But blindly accepting AI output is dangerous. What if it rewrites your carefully tuned `WHERE` clause? What if it changes the aggregation logic? You need to review before applying.

**The solution:** CMD+K opens a prompt overlay. Describe what you want in natural language. The AI generates SQL. But instead of replacing your code immediately, we show a diff view. You see exactly what changed before deciding.

{% html %}
<iframe width="560" height="315" src="https://www.youtube.com/embed/H1Not3pym6M" title="AI-assisted SQL with diff preview" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
{% /html %}

```tsx
// PlaygroundSqlEditor.tsx

const { submit: submitSql, isLoading: isLoadingSql } = usePlaygroundSql(
  playground,
  nodeName || '',
  {
    onFinish: newSql => {
      setPromptVisible(false)
      if (!!sql && newSql !== sql) {
        setDiff(newSql)
        setProposedQuery(true)
        onRun?.(newSql, nodeName || '', undefined, undefined, undefined, false)
      } else {
        setSql(newSql)
        onChange?.(newSql)
        onRun?.(newSql, nodeName || '')
      }
    }
  }
)
```

When the AI returns SQL that differs from your current query, we set `diff` state and flip to `proposedQuery` mode. The editor then renders a `PlaygroundSqlDiffEditor` instead of the normal editor:

```tsx
// PlaygroundSqlDiffEditor.tsx

export function PlaygroundSqlDiffEditor({
  original,
  modified
}: {
  original: string
  modified: string
}) {
  useEffect(() => {
    const state = EditorState.create({
      doc: modified,
      extensions: [
        EditorState.readOnly.of(true),
        EditorView.editable.of(false),
        tinybirdSQL(),
        diffTheme,
        unifiedMergeView({
          original,
          highlightChanges: true,
          gutter: false,
          mergeControls: false
        })
      ]
    })
    // ...
  }, [original, modified])
}
```

The diff view uses CodeMirror's `unifiedMergeView` extension. Same editor, same Tinybird SQL syntax highlighting, but with diff semantics layered on top. Green backgrounds show additions. Red shows deletions. Changed text gets an underline.

You get three buttons:

- **Reset results/Run proposed query**: AI's version is executed and shows results without accepting it yet — test before committing
- **Accept**: Apply the changes permanently
- **Reject**: Discard and return to your original SQL

This workflow mirrors what developers expect from code review. You see the diff. You test it. You accept or reject. The AI suggests; you decide.

## What We Learned

**Naming collisions are real.** When users create playgrounds, they often use generic names like "test" or "debug". Multiple users in the same workspace hitting collisions. Error messages about duplicate names. We added a function to generate unique suffixes automatically: `test-abc123` instead of just `test`. The suffix is a short ULID fragment — unique enough to prevent collisions, short enough to stay readable.

**Routes should use IDs, not names.** Early on, we routed to `/playgrounds/my-playground-name`. It looked nice in the URL bar. But names can change. Renaming a playground broke bookmarks and shared links. We switched to `/playgrounds/{id}` and added name resolution on load. The URL is less pretty, but it's stable.

**Testing with accessible roles beats data-testid.** We added UI tests with Vitest browser mode. Our first instinct was `data-testid` everywhere — the standard React Testing Library approach. But accessible roles — `getByRole('button', { name: 'Run' })` — turned out more stable and self-documenting. When you refactor the component tree, roles stay the same. When you rename a component, roles stay the same. It also forces you to write accessible markup: if the test can't find a button by role, maybe screen readers can't either.

**Non-owners need limited edit permissions.** You can see a shared playground. You can even edit the SQL and run queries — that's the whole point of sharing. But you shouldn't be able to change who else can see it. That's the owner's prerogative. The API detects when a non-owner tries to modify `shared_with` and ignores that field. Other fields get updated normally. Ownership stays clear.

**Auto-save needs debouncing.** SQL changes save automatically — no explicit save button. But saving on every keystroke hammers the API. We debounce writes with a 500ms delay. Type, pause, save. It feels instant to users but keeps API traffic reasonable.

## Try It

Playgrounds is available now in Forward. Open your workspace, look for it in the navigation, and start writing SQL.

No terminal. No pipe files. Just queries.

{% cta
  title="Try Playgrounds"
  text="Write SQL, hit run, see results."
  button={href: "https://cloud.tinybird.co", target: "_blank", text: "Go to your Workspace"}
/%}