// Tableau + AI · Claude · Tableau Cloud · Extensions API

Build a Tableau Data App in 9 Steps (Without the 429)

The exact build I use for every Tableau data app on Tableau Cloud: scaffold, wire, put the fields on the sheet, read the sheet's own data, package and publish. It never touches the VizQL Data Service, so it keeps working when live queries fail.

NOV 11, 2026

I've built a couple dozen Tableau data apps on Tableau Cloud now, and one build pattern has worked every single time. This is that pattern, nine steps, exactly as I run it.

A data app is a viz extension that ships inside the workbook. Claude scaffolds it with Tableau's scaffold-data-app tool, writes the app, packages a .twbx and publishes it. No hosting, no .trex pointing at your own server.

Why this pattern?

The obvious way for a data app to get data is to query it live with queryAsync. On my site that fails. Every time, on every kind of data I tried (embedded CSV, embedded Excel, an extract, a published Hyper source), queryAsync and readMetadataAsync both come back with a generic internal-error.

Both of those calls go through Tableau's VizQL Data Service. On the same site, a single Data Service metadata call through the Tableau MCP returns 429 while plain REST calls work fine. So my site is over its Data Service allowance, and it stays over.

I haven't proven the 429 is what breaks queryAsync. The app only ever says internal-error. What I can say for sure: a data app that reads its own sheet's data never touches that service, and it works every time. So that's how I build them.

The trade-off: the app shows what's on its sheet. It can't run whatever query it wants at runtime. To change the data, you change the fields on the sheet and republish.

Before you start

  • Settings → Packages → Allow Packages on this Site is checked and saved. Without it, step 1 fails with 403 Forbidden.
  • Your site role is Creator or Explorer (can publish).
  • Claude has the Tableau Cloud connector and the Tableau plugin (which brings the tableau-data-app-authoring skill).

Step 1: Scaffold the app

Ask Claude to call scaffold-data-app. Tableau hands back a starter app as a zip, and the download link expires in about 60 seconds, so it has to be downloaded right away. If the link expires, call scaffold again.

Step 2: Get the field names without the Data Service

You need the exact field names, but don't spend Data Service calls finding them.

  • Embedded CSV or Excel: read them from the file's header row.
  • Published data source: find it with list-datasources (REST, not the Data Service) and get the field names from the workbook XML or from whoever owns the source.

Skip the MCP's get-datasource-metadata and query-datasource tools here. Those are the Data Service calls that hit the 429.

Step 3: Wire the data source

The scaffolded workbook has no data connected. Wire it with the plugin's wire_datasource.py script. Don't hand-edit that XML.

On Windows, run the script through a small wrapper that turns the backslashes in its paths into forward slashes.

Step 4: Declare a Data encoding in the .trex

In extensions/data-app.trex, declare one encoding called field. It becomes a Data slot on the Marks card:

XML
<encoding id="field">
  <display-name>Data</display-name>
  <role-spec>
    <role-type>discrete-dimension</role-type>
    <role-type>discrete-measure</role-type>
    <role-type>continuous-dimension</role-type>
    <role-type>continuous-measure</role-type>
  </role-spec>
  <encoding-icon token="level" />
</encoding>

Step 5: Put the fields on that encoding in the .twb

This is the step that makes the whole thing work. Inside the worksheet, two edits.

First, in the view's datasource-dependencies for your data source, add a column for each field and then a column-instance for each:

XML
<column-instance column='[Region]' derivation='None' name='[none:Region:nk]' pivot='key' type='nominal' />
<column-instance column='[Sales]'  derivation='Sum'  name='[sum:Sales:qk]'  pivot='key' type='quantitative' />

Second, in the pane, right after the add-in element, add the encodings:

XML
<encodings>
  <custom encoding-id='{NEW-GUID}' column='[federated.XXX].[none:Region:nk]' custom-type-name='field' />
  <custom encoding-id='{NEW-GUID}' column='[federated.XXX].[sum:Sales:qk]' custom-type-name='field' />
</encodings>

Use the same federated.XXX data source name everywhere, and give every encoding its own new GUID.

Now Tableau queries those fields itself when it draws the sheet, the same way it would for a bar chart. The app just reads the result.

Step 6: Read the sheet's data in app.js

JavaScript
await tableau.extensions.initializeAsync();
const sheet = tableau.extensions.worksheetContent.worksheet;
const reader = await sheet.getSummaryDataReaderAsync(undefined, {
  ignoreSelection: true,
});
const table = await reader.getAllPagesAsync();
await reader.releaseAsync();
// table.columns[i].fieldName
// table.data[row][col].value and .formattedValue

That's the only data call. Never call queryAsync or readMetadataAsync. If you need row-level data instead of the sheet's aggregates, use getLogicalTableDataReaderAsync. It goes the same route and works on Cloud.

Everything after getAllPagesAsync() is ordinary web code. That's the part Claude writes from your description.

Step 7: Bump the version in four places

Before every publish, set the same new version in:

  1. manifest.json
  2. the .trex extension-version
  3. the .twb, twice: on the add-in element and under referenced-extensions
  4. app.js, if the app shows its version

Miss one and Tableau keeps serving the cached package, so your fix seems to do nothing.

Step 8: Package with the script

Package with the plugin's package_twbx.py. Never zip by hand. An extra folder level inside the zip breaks the workbook, and it's an easy mistake to make.

Step 9: Publish

  1. Call request-workbook-upload to get an upload URL.
  2. Upload the .twbx within 60 seconds:
Shell
curl -X PUT -H "Content-Type: application/octet-stream" \
  --data-binary "@My App.twbx" "<upload url>"
  1. Call publish-workbook with the upload id and overwrite: true. With no project named, it lands in your Personal Space.

All three are REST calls. None of them touch the Data Service.

How do you know it worked?

Open the workbook's sheet in Tableau Cloud and the app loads with your data. Check Settings → Packages → Packages to confirm the new version landed.

In the site's activity log, a data app built this way only shows create-summary-data-table-reader and get-data-table-reader-page reads. No Data Service traffic at all.

If you're new to connecting Claude to Tableau, start with Tableau MCP Server: Connect Claude to Tableau. For extensions you host yourself instead of shipping inside the workbook, see Build a Tableau Extension with Claude.

Frequently asked questions

What is a Tableau data app?
A viz extension packaged inside the workbook itself. Claude scaffolds it with the scaffold-data-app tool, writes the app, packages a .twbx and publishes it to Tableau Cloud. There's nothing to host.
Why does queryAsync fail with internal-error in my data app?
queryAsync and readMetadataAsync go through the VizQL Data Service. On a site where Data Service calls return 429, both fail with a generic internal-error. A data app that reads its own sheet's data with getSummaryDataReaderAsync doesn't use that service and keeps working.
Why does scaffold-data-app return 403?
Allow Packages on this Site is off. A site admin turns it on under Settings, Packages, and clicks Save. You also need a Creator or Explorer (can publish) site role.
I republished my data app but still see the old version. Why?
Tableau caches the package by version. Bump the version in manifest.json, the .trex and both places in the .twb, then republish.