+1 (726) 227-2971

Building a Looker Extension: Extension Framework, Components, and Write-Back

Sooner or later every serious Looker deployment hits the same wall: the answer lives in a dashboard, but the work does not. Someone has to approve the flagged order, write a note back to the CRM, re-run the forecast with a different assumption, or trigger a backfill. Dashboards are read-only by design, and custom visualizations only let you change how a single tile draws itself.

The Extension Framework is Looker's answer. It lets you ship a full React/TypeScript application inside Looker — inside the navigation, behind Looker's authentication, with the Looker API 4.0 SDK already wired up. No separate hosting, no separate login, no separate permission model. This tutorial builds a small extension end to end: scaffolding, the LookML manifest, calling the API, rendering with Looker Components, writing data back, and shipping it via Git and the Marketplace.

When an extension is the right tool

Before you build, be honest about the cheaper options:

NeedReach for
Different chart type on one tileCustom visualization (Custom Viz API)
Same dashboard, filtered per viewerUser attributes + access_filter
Looker content inside your productSigned embedding / Embed SDK
Multi-step workflow, forms, write-back, custom navigation, several queries stitched togetherExtension Framework

The rule of thumb: if you need state and actions, not just a picture, you need an extension. If your users live in another product, embed instead — extensions run inside Looker.

Step 1: scaffold the project

Extensions are Node projects. The generator gives you a working skeleton in one command:

npx create-looker-extension my-ops-console
# choose: React + TypeScript
cd my-ops-console
npm install
npm run develop     # serves a dev bundle on https://localhost:8080/bundle.js

npm run develop starts a local dev server. Looker loads your JavaScript from localhost while you iterate, so hot reload works exactly as it would in any React app. Nothing is deployed yet.

Step 2: declare the extension in LookML

An extension is registered in a LookML project's manifest.lkml. Create a new project (or use an existing one) and add:

project_name: "ops_console"

application: ops_console {
  label: "Ops Console"
  # Dev: load from the local dev server. Swap to file: for production.
  url: "https://localhost:8080/bundle.js"
  # file: "bundle.js"

  entitlements: {
    core_api_methods: [
      "all_lookml_models",
      "lookml_model_explore",
      "run_inline_query",
      "me"
    ]
    navigation: yes
    use_embeds: yes
    use_form_submit: yes
    external_api_urls: ["https://api.internal.example.com"]
    scoped_user_attributes: ["ops_console_region"]
  }
}

Two things deserve attention.

entitlements is an allow-list, and it is enforced. If you call an API method that is not listed, the SDK request fails — even if the signed-in user has permission. This is the feature that makes extensions reviewable: a security-minded admin can read the manifest and know the blast radius. List the minimum set and expand as you go.

url: versus file:. During development, point at your dev server. For production, build the bundle and commit it into the LookML project so Looker serves it itself. Never leave a url: pointing at a laptop in a shared branch.

After saving the manifest, commit and deploy the LookML branch, then reload Looker. Your extension appears under Applications in the left navigation.

Step 3: talk to Looker from React

The scaffold wires up ExtensionProvider, which injects an authenticated SDK. There is no API key and no OAuth dance: the extension inherits the browsing user's session and permissions.

import React, { useContext, useEffect, useState } from 'react'
import { ExtensionContext } from '@looker/extension-sdk-react'

export const SlowExplores: React.FC = () => {
  const { core40SDK } = useContext(ExtensionContext)
  const [rows, setRows] = useState<any[]>([])
  const [error, setError] = useState<string>()

  useEffect(() => {
    const load = async () => {
      try {
        const result = await core40SDK.ok(
          core40SDK.run_inline_query({
            result_format: 'json',
            body: {
              model: 'system__activity',
              view: 'history',
              fields: [
                'query.model',
                'query.view',
                'history.average_runtime',
                'history.query_run_count'
              ],
              filters: { 'history.created_date': '7 days' },
              sorts: ['history.average_runtime desc'],
              limit: '20'
            }
          })
        )
        setRows(result as any[])
      } catch (e: any) {
        setError(e.message ?? 'Query failed')
      }
    }
    load()
  }, [core40SDK])

  if (error) return <>{error}</>
  return <>{rows.length} explores loaded</>
}

core40SDK.ok(...) unwraps the response and throws on error, which is what you want inside a try/catch. Because the query runs as the signed-in user, every access_filter and model-set restriction still applies — an extension is not a permission bypass.

Step 4: render with Looker Components

You can style everything yourself, but @looker/components gives you the same design system Looker's own UI uses, so your app does not look like a stapled-on side project:

import { ComponentsProvider, Box, Heading, DataTable,
         DataTableItem, DataTableCell, Button } from '@looker/components'

<ComponentsProvider>
  <Box p="large">
    <Heading as="h2">Slowest explores, last 7 days</Heading>
    <DataTable caption="Slow explores" columns={columns}>
      {rows.map((r, i) => (
        <DataTableItem key={i} id={String(i)}>
          <DataTableCell>{r['query.model']}</DataTableCell>
          <DataTableCell>{r['query.view']}</DataTableCell>
          <DataTableCell>{r['history.average_runtime']?.toFixed(1)}s</DataTableCell>
        </DataTableItem>
      ))}
    </DataTable>
    <Button mt="medium" onClick={flagForReview}>Flag for review</Button>
  </Box>
</ComponentsProvider>

Need an actual Looker tile inside your app? The @looker/embed-sdk works from within an extension (that is what use_embeds: yes unlocks), so you can drop a real dashboard or Explore next to your custom controls instead of re-implementing charting.

Step 5: write-back, done safely

The interesting half of most internal tools is the write. Extensions can call external endpoints — but only URLs you declared in external_api_urls, and you should never ship a bearer token in front-end code.

The clean pattern is to keep secrets on the server side and let the extension call a thin proxy:

const flagForReview = async () => {
  const me = await core40SDK.ok(core40SDK.me())
  await extensionSDK.fetchProxy(
    'https://api.internal.example.com/looker/flag',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        explore: selected,
        requested_by: me.email,
        note
      })
    }
  )
}

extensionSDK.fetchProxy routes the request through Looker's server, which means CORS is handled and — with a configured external API credential — Looker can inject the secret so the browser never sees it. For anything that changes rows in a warehouse, a Looker Action (Action Hub) is often a better destination than a bespoke endpoint: you get delivery logging and a schema-validated form for free.

Two guardrails worth writing down before your first write-back ships:

  1. Authorise on the server, not in the UI. Hiding a button is not a permission. Have your endpoint verify the caller's identity and entitlements independently.
  2. Log who did what. core40SDK.me() gives you the acting user; store it with every mutation. "The dashboard did it" is not an audit trail.

Step 6: build, commit, deploy

When the app is ready, build a production bundle and serve it from Looker rather than your machine:

npm run build          # emits dist/bundle.js
cp dist/bundle.js ../ops_console/bundle.js

Then in manifest.lkml, comment out url: and enable file: "bundle.js". Commit both the manifest and the bundle to the LookML project, open a pull request, and deploy to production the same way you deploy any LookML change. The bundle is a build artifact in Git, so treat it like one: it should always be regenerated by CI from the committed source, never hand-edited.

To control who sees the app, use the extension permissions on the role attached to the model set containing the project. Access to an extension is Looker permissioning, not something you invent in React.

Packaging it for other Looker instances? Add a marketplace.json and a README, and the same repository can be installed from the Looker Marketplace as a private "install via Git URL" app — the standard way to distribute an internal tool across dev, staging and production instances.

A short pre-flight checklist

  • Entitlements are minimal, and external_api_urls lists only what you call.
  • file: (not url:) in any branch that gets deployed.
  • Secrets live behind fetchProxy or an Action, never in the bundle.
  • Queries run through Explores so access_filter and user attributes still bite.
  • Errors are surfaced in the UI; core40SDK.ok() failures are caught, not swallowed.
  • The bundle is CI-built and version-controlled alongside the LookML project.

Where this pays off

The extensions that earn their keep are rarely flashy. They are approval queues, data-quality triage consoles, forecast scenario tools, self-serve backfill triggers, and "which of my 400 dashboards is nobody using" clean-up apps built on System Activity. Each one replaces a spreadsheet plus a Slack thread, and each one lives where the numbers already are — behind the same login, the same row-level security, the same governed definitions.

If you are weighing whether a workflow belongs in an extension, an embedded app, or a plain dashboard, our Looker consultants and Looker app developers do this design work every week — get in touch with what you are trying to automate.