Build a payments dashboard without building a dashboard

  |   AG Studio

Last month, we ran a live session for the PayPal AI Hackathon where we put AG Studio to the test. We pulled sandbox data from the PayPal REST API, handed it to AG Studio, and let the dashboard build itself. You can watch the full recording on YouTube.

For newcomers, AG Studio is an embedded analytics component from the team behind AG Grid and AG Charts. Rather than building each dashboard yourself, you describe your data to AG Studio once and drop it into your app. Your users then get a drag-and-drop report builder: grids, charts and KPIs they can arrange, filter and save themselves, with an optional AI assistant that can build reports for them, using your own LLM:

0:00
/0:46

This post is the AG Studio half of that session, written as a step-by-step tutorial. For part 1, which is about the PayPal Sandbox and the SDK/API, head to PayPal Developer blog post by Eddie Jaoude. 

TL;DR

  • The server fetches from the API and flattens every response into plain rows. Credentials never leave the server; the browser only ever sees rows.
  • AG Studio needs one object to describe that data: sources (one table per endpoint), relationships (how they join), expressions (calculated measures) and calendars (date grouping).
  • <AgStudio data={...} /> turns that into a drag-and-drop canvas with 30+ grid and chart widgets, global filters and cross-filtering - no dashboard code required.
  • A theme, an initialState and a few API calls make it look and behave like part of your app, with a pre-built report, edit/view modes, undo and redo, and saved reports.
  • An AI assistant is one module, one adapter and a small server-side proxy away.

What we'll cover

  1. What we're building: the finished dashboard and how it's put together.
  2. Loading the data: fetching from the API and flattening it into rows on the server.
  3. Defining the data model: sources, relationships, expressions and calendars.
  4. Rendering AG Studio: from data to drag-and-drop report builder.
  5. Theming: making Studio look like part of your app.
  6. Pre-building reports: shipping a ready-made report as the initial state.
  7. Connecting Studio to your UI: edit and view modes, undo and redo, saving reports and changing pages.
  8. Adding an AI assistant: the Studio Agent Framework, an LLM adapter and a server-side proxy.
  9. Bring your own API: applying all of this to data that isn't from PayPal.

Whilst the example uses PayPal throughout, nothing in the AG Studio setup is PayPal-specific. If you can fetch JSON from an API and flatten it into rows, everything below applies.

Prefer code to prose? The full source is in the PayPal + AG Studio boilerplate on GitHub.

What we're building

The end result is a multi-page payments dashboard for a merchant that sells subscriptions and sends invoices: revenue, receivables, cash and payouts, and the product catalogue each have their own report. Users can switch between these reports, edit them, and undo their changes.

There are two pieces to it. The host app provides the shell: the sidebar, header and buttons. Inside it sits AG Studio, the embedded analytics component that powers all of the reporting.

Users can click around a report and cross-filter it: interacting with one widget filters all of the others. And if they like what they see, but not quite, they can change it themselves. Swap the donut chart for a pie chart, remove the gauge to give the bar chart more room, hit Ctrl+Z, change their mind again, redo, and save. They can flip between pre-built reports, or start a new one from scratch and let their imagination go wild.

0:00
/1:42

If you look closely, you'll also spot an AI assistant panel in there. I'm going to leave you on a bit of a cliffhanger with that one, and we'll get to it right at the end.

A look at the architecture

The app is a Next.js project, and the work splits cleanly into three places:

  1. External APIs. The PayPal REST API for invoices, catalogue products and plans, transactions and balances - plus OpenAI for the assistant.
  2. The server. A single function fetches every endpoint and flattens the results into rows. A second route proxies AI requests and adds the OpenAI key.
  3. The browser. Our own React app (sidebar, header, saved reports) wraps a single <AgStudio> component, which owns the data model, the report being edited, and the AI chat.
Architecture diagram of a Next.js app where the server fetches PayPal REST API data and proxies OpenAI requests, passing plain data rows to an AgStudio component in the browser that syncs with the host React app via AgStudioApi and onStateUpdated events.
The Next.js server fetches PayPal data and proxies OpenAI calls, so the browser only receives plain rows and the credentials never leave the server.

The important thing to note is the boundary: the browser only ever receives plain rows. PayPal and OpenAI credentials stay on the server, and AG Studio never talks to either API directly.

The key thing to remember is that the host drives AG Studio through its props (such as mode) and its API (setState, getState, undo, redo), and AG Studio reports every change back through the onStateUpdated event. Every example that follows in this post uses this pattern.

Loading the data

AG Studio works with arrays of plain objects, so the first job is getting the API responses into that shape. In our case, that happens in a server component that renders per request (so the data is live, not baked in at build time):

// app/dashboard/page.tsx

import { connection } from 'next/server';
import { getDashboardData } from '@/lib/dashboard-data';
import { Dashboard } from './dashboard';

export default async function DashboardPage() {
  // Live data: render per request, not at build time.
  await connection();

  // Calls the PayPal REST API, then turns every response into flat rows
  const data = await getDashboardData();

  // Pass the rows to the client component, which renders AG Studio
  return <Dashboard data={data} licenseKey={process.env.AG_STUDIO} />;
}

getDashboardData() calls five endpoints in parallel and returns one array per "table" we want to report on:

// lib/dashboard-data.ts

export async function getDashboardData(asOf = new Date()) {
  const [products, plans, invoices, transactions, balances] = await Promise.all([
    listProducts(),
    listPlans(),
    listInvoices(),
    searchTransactions(/* last 31 days */),
    getBalances(),
  ]);
  const today = asOf.toISOString().slice(0, 10);

  return {
    asOf: asOf.toISOString(),
    customers: customerRows(invoices),
    invoices: invoiceRows(invoices, today),
    invoiceItems: invoiceItemRows(invoices),
    invoicePayments: invoicePaymentRows(invoices),
    products: productRows(products),
    plans: planRows(plans),
    transactions: transactionRows(transactions),
    payouts: payoutRows(transactions),
    balances: balanceRows(balances),
  };
}

The row mappers have been left out for brevity - they're in the repository if you're curious.

Notice that five endpoints become nine tables. API responses are rarely flat: an invoice contains a recipient, line items and payments, so we split each of those out into its own table, with an ID pointing back to the parent invoice. This is the same normalisation you'd do for a relational database, and it's what lets AG Studio join everything back together later.

It's also the right place to do any domain-specific clean-up. A few examples from this project:

  • PayPal amounts are decimal strings with a currency ({ value: "2.99", currency_code: "USD" }), so they're converted to numbers.
  • Derived values that need business rules, like daysOverdue or an aging bucket, are calculated once on the server, rather than by every widget.
  • Refunds get a negative signedAmount, so users can sum it to get net cash.

NOTE: This demo uses the client-side approach, where AG Studio manages all of the data on the client-side. In our testing, this approach can comfortably handle up to about 1,000,000 rows and 21 columns. However, we also offer server-side data configuration within AG Studio. With server-side data, AG Studio will pass a query object to your backend, which can execute the query and return the relevant data to AG Studio.

Finally, to run this demo in production, you’ll need an AG Studio licence key. Get a free 45-day trial key and add it to .env.local as AG_STUDIO. Production or commercial usage requires a full licence.

Defining the data model

With the rows in hand, we need to tell AG Studio what they are. This is done with a single AgDataSourcesDefinition object, built from four parts: sources, relationships, expressions and calendars.

There's a fair bit here, but most of it is optional. Sources are the only part that's 100% required; how much you need of the other three really depends on how complex your data set and use case are. We'll build it up one piece at a time.

Sources

Each REST endpoint (or each table we split out of one) becomes a source. A source is just an id, the rows, and a definition for each field you want users to work with:

// app/dashboard/studio/data.ts

import type { AgDataSourcesDefinition } from 'ag-studio';
import type { DashboardData } from '@/lib/dashboard-data';

export function buildStudioData(data: DashboardData): AgDataSourcesDefinition {
  return {
    description: 'PayPal Sandbox data for a merchant that sells subscription products and sends invoices. ' +
      'Invoices are in USD, EUR or GBP and are not converted, so filter by currency before summing money.',
    sources: [
      {
        id: 'invoices',
        description: 'One row per invoice.',
        data: data.invoices,
        fields: [
          { id: 'id', name: 'Invoice ID', format: 'textFormat', hide: true },
          { id: 'number', name: 'Invoice', format: 'textFormat' },
          { id: 'customerId', name: 'Customer ID', format: 'textFormat', hide: true },
          { id: 'invoiceDate', name: 'Invoice Date', format: 'dateFormat' },
          { id: 'issuedAmount', name: 'Invoiced', format: 'currencyFormat' },
          { id: 'paidAmount', name: 'Paid', format: 'currencyFormat' },
          // ...additional fields
        ],
      },
      // ...customers, invoiceItems, invoicePayments, products, plans, transactions, payouts, balances
    ],
  };
}

Then it's just a case of rinse and repeat for every table. A few things worth calling out:

  • name is what users see in the Data panel, so it's worth making these friendly. Nobody wants to drag issued_amount onto a chart.
  • format tells AG Studio how to display and aggregate the field: currencies, percentages, integers, dates and so on.
  • hide keeps a field out of the UI whilst still letting Studio use it - perfect for IDs that only exist to join tables together.
  • description is optional, but worth writing. It's context for your report authors and for the AI assistant we'll add later. Notice the top-level description warns that currencies aren't converted; that's exactly the kind of thing you'd want an LLM to know before it starts summing invoices.

Relationships

Remember how we split invoices into customers, line items and payments? Relationships join them back together, so users can query across sources without you writing any joins. They work in the same way as relationships in a relational database:

export function buildStudioData(data: DashboardData): AgDataSourcesDefinition {
  return {
    sources: [ /* ... */ ],
    relationships: [
      // Each invoice belongs to a single customer
      {
        id: 'invoice-customer',
        type: 'many-to-one',
        source: { tableId: 'invoices', fieldId: 'customerId' },
        target: { tableId: 'customers', fieldId: 'id' },
      },
      // Each line item belongs to a single invoice
      {
        id: 'item-invoice',
        type: 'many-to-one',
        source: { tableId: 'invoiceItems', fieldId: 'invoiceId' },
        target: { tableId: 'invoices', fieldId: 'id' },
      },
      // ...payments -> invoices, plans -> products
    ],
  };
}

Now a user can build a chart of invoice totals by customer name, even though the name lives in a different table to the amount. Studio follows the relationship for them.

Expressions

Some of the most useful numbers aren't in the API response at all. Collection rate, for example, is paid divided by invoiced - but you can't pre-calculate it per row, because the ratio of sums isn't the sum of ratios.

Expressions solve this by defining calculated fields that Studio evaluates after filtering and aggregation:

export function buildStudioData(data: DashboardData): AgDataSourcesDefinition {
  return {
    sources: [ /* ... */ ],
    relationships: [ /* ... */ ],
    expressions: [
      {
        id: 'collectionRate',
        name: 'Collection Rate',
        description: 'Paid / invoiced. Filter to one currency first.',
        format: 'percentageFormat',
        isMeasure: true, // true = a single aggregated value, false = a new column
        expression: {
          operator: 'divide',
          inputs: [
            { id: 'invoices.paidAmount', aggregation: 'sum' },
            { id: 'invoices.issuedAmount', aggregation: 'sum' },
          ],
        },
      },
      // ...overdueShare, refundRate
    ],
  };
}

Because it's a measure, collectionRate responds to every filter on the page. Filter to one customer, and you get that customer's collection rate. Users can also create their own calculations in the UI, so you only need to define the ones you want available out of the box.

Calendars

Finally, dates. Payments data is almost always analysed over time - by month, by quarter, by day of the week - and you don't want to add a month, quarter and week column to every table.

A calendar defines those groupings once, over the full date range in the data:

export function buildStudioData(data: DashboardData): AgDataSourcesDefinition {
  // Every date in the data, so the calendar covers exactly that range
  const dates = [
    ...data.invoices.map((r) => r.invoiceDate),
    ...data.invoicePayments.map((r) => r.date),
  ].filter(Boolean).sort();

  return {
    sources: [ /* ... */ ],
    expressions: [ /* ... */ ],
    calendars: [
      {
        id: 'calendar',
        label: 'Calendar',
        range: {
          from: { type: 'date', value: dates[0] },
          to: { type: 'date', value: dates.at(-1) },
        },
        fragments: ['year', 'quarter', 'month', 'week', 'day', 'dayOfWeek'],
      },
    ],
    relationships: [
      /* ...table relationships */
      // Connect each date field to the calendar
      { id: 'invoices-invoiceDate-calendar', source: { tableId: 'invoices', fieldId: 'invoiceDate' }, target: { calendarId: 'calendar' } },
      { id: 'invoicePayments-date-calendar', source: { tableId: 'invoicePayments', fieldId: 'date' }, target: { calendarId: 'calendar' } },
      // Date-time fields can be truncated to the day
      { id: 'transactions-date-calendar', source: { tableId: 'transactions', fieldId: 'date' }, target: { calendarId: 'calendar' }, truncate: 'day' },
    ],
  };
}

The step that's easy to miss is the last one: a calendar on its own does nothing until you connect date fields to it with a relationship. Once you do, a single calendar::month field groups invoices, payments and transactions by the same months - which is what makes "invoiced vs. paid, by month" a two-click chart rather than a data modelling exercise.

In the repository, these definitions are built with a few small helper functions to cut down on repetition, but the output has the same shape as the examples above.

Rendering AG Studio

With the data model defined, rendering AG Studio is the shortest step in this whole post. In a client component, build the data definition from the rows, and pass it to <AgStudio>, wrapped in an <AgStudioProvider> that holds the licence key:

// app/dashboard/dashboard.tsx
'use client';

import { useMemo } from 'react';
import { AgStudio, AgStudioProvider } from 'ag-studio-react';
import type { DashboardData } from '@/lib/dashboard-data';
import { buildStudioData } from './studio/data';

export function Dashboard({ data, licenseKey }: { data: DashboardData; licenseKey?: string }) {
  // Only rebuild the data definition when the rows change
  const studioData = useMemo(() => buildStudioData(data), [data]);

  return (
    <AgStudioProvider licenseKey={licenseKey}>
      <AgStudio data={studioData} style={{ height: '100%', width: '100%' }} />
    </AgStudioProvider>
  );
}

And that's it. Not a single widget, filter or layout has been written, but we already have a working report builder:

  • Data: every field from all nine tables, with friendly names, ready to drag onto the canvas.
  • Compose: 30+ AG Grid and AG Charts widgets - grids, KPIs, bar, line, combo, gauge, sunburst and more.
  • Filters: global and page-level filters on any field, plus cross-filtering between widgets.
0:00
/0:18

Try it yourself by finding your most loyal customers. Drop a bar chart onto the canvas, add the customer names, and sum the price of the items they bought. A few clicks later, Margaret and Catherine Johnson top the list in our sandbox data (Linus has a little work to do). None of that is code you have to write.

If you stopped here, you'd have a perfectly usable self-serve payments dashboard. But how do we make it feel like your product, rather than a component dropped into it?

Theming

AG Studio, and the grids and charts inside it, are styled with a single theme object. Start from studioTheme and override whichever parameters you need with withParams:

// app/dashboard/studio/theme.ts

import { studioTheme } from 'ag-studio';

export const agStudioPayPalTheme = studioTheme.withParams({
  // Shared by Studio's own UI, grids and charts
  fontFamily: 'var(--pp-font)',
  textColor: 'var(--pp-ink)',
  backgroundColor: 'var(--pp-surface)',
  borderColor: 'var(--pp-border)',
  accentColor: 'var(--pp-blue)',

  // Widgets match the rest of the app's cards
  studioWidgetBorderRadius: 8,
  studioWidgetTitleTextColor: 'var(--pp-navy)',

  // Grids and charts
  gridHeaderTextColor: 'var(--pp-muted)',
  gridRowHeight: 40,
  chartPaletteFills1Color: 'var(--pp-chart-1)',
  chartPaletteFills2Color: 'var(--pp-chart-2)',

  // ...other theme parameters
});

Then pass it to the component:

<AgStudio data={studioData} theme={agStudioPayPalTheme} />

The trick worth stealing here is that every value is a CSS variable - the same design tokens the rest of the app uses. Change --pp-blue in your global stylesheet, and the sidebar, header, widgets, grids and charts all follow. That's what makes Studio look like part of the page, rather than something embedded in it.

The theme reaches pretty much everything: the canvas, widget cards, titles, the edit-mode panels, grid headers and rows, and the chart palette. The repo's full theme is a good reference if you want to see how far it goes.

With hundreds of parameters to choose from, hand-writing a theme can be a bit daunting. To make things easier, we also have a Theme Builder - you'll find it in the nav bar on the AG Studio website. Start from one of the pre-built themes, tweak individual values in the sidebar and watch them update in real time, then hit the Import/Export button to copy the code straight into your app. It’s a quick way to design your theme without leaving the browser. 

Pre-building reports: giving users a place to start

A blank canvas is great for power users, but most people want to open a dashboard and see something useful straight away. Rather than making them start from scratch, we can ship a pre-built report as Studio's initial state.

Every report in AG Studio is a plain, serialisable AgReportState object. It contains a list of pages, and each page (AgPageState) defines its own widgets, layout and filters:

import type { AgPageState, AgReportState } from 'ag-studio';

const page: AgPageState = {
  id: 'page-id',
  widgets: { /* what each widget shows */ },
  widgetLayout: { /* where each widget sits on the canvas */ },
  filter: { /* filters applied to the whole page */ },
};

export const dashboardReport: AgReportState = {
  selectedPageId: 'page-id',
  pages: [page],
};

Here's a real example: the grid of open invoices from the "Receivables" page, along with its layout and page-level filters:

// app/dashboard/studio/initial-report.ts
const receivables: AgPageState = {
  id: 'receivables',
  widgets: {
    'open-invoices': {
      type: 'grid',
      dataMapping: {
        cols: [
          { id: 'invoices.number' },
          { id: 'customers.name' }, // from a related table
          { id: 'invoices.dueDate' },
          { id: 'invoices.daysOverdue' },
          { id: 'invoices.outstanding', aggregation: 'sum' },
        ],
      },
      sort: [{ field: { id: 'invoices.daysOverdue' }, direction: 'desc' }],
      format: { title: { enabled: true, text: 'Open invoices, most overdue first' } },
    },
    // ...KPIs, an aging chart, a currency filter
  },
  widgetLayout: {
    // Positions on a 24-column grid: x, y, width, height
    'open-invoices': { xTrack: 0, yTrack: 25, xSpan: 24, ySpan: 20 },
    // ...more widgets
  },
  filter: {
    page: [
      { field: { id: 'invoices.currency' }, model: { operator: 'isIn', value: ['USD'] } },
      { field: { id: 'invoices.isReceivable' }, model: { operator: 'isTrue' } },
    ],
  },
};

export const dashboardReport: AgReportState = {
  selectedPageId: 'revenue',
  pages: [revenue, receivables, cash, catalogue],
};

Everything references fields by tableId.fieldId - the same IDs we defined in the data model - so customers.name sits happily alongside invoices.number thanks to the relationship between them.

We've trimmed this right down to fit, but you can keep adding more widgets to the widgets object - grids, charts, KPI cards, titles, images, and your own custom components - and position each one on the canvas's XY grid in widgetLayout.

Then pass the report to <AgStudio> as its initialState:

import { dashboardReport } from './studio/initial-report';

export function Dashboard({ data, licenseKey }: DashboardProps) {
  const studioData = useMemo(() => buildStudioData(data), [data]);

  return (
    <AgStudioProvider licenseKey={licenseKey}>
      <AgStudio
        data={studioData}
        theme={agStudioPayPalTheme}
        initialState={dashboardReport}
      />
    </AgStudioProvider>
  );
}

Now AG Studio opens on a ready-made report, in PayPal's colours, with cross-filtering and every other feature working out of the box. Users don't have to build anything from scratch:

0:00
/0:15

As the name suggests, initialState is only read once, when AG Studio is created. To change the report after that, use the API - which is exactly what we'll do next.

Top tip: you don't need to write report state by hand. Build the report in the UI, call api.getState() (or log it from onStateUpdated), and paste the result into your code.

Connecting Studio to your UI

So far, everything has happened inside AG Studio. But a real app has its own header, sidebar and buttons, and they need to control the report. This is where the pattern from the architecture comes in: the host drives Studio through props and the API, and Studio reports back through events.

To get hold of the API, store it in a ref when AG Studio is ready:

const apiRef = useRef<AgStudioApi | null>(null);

<AgStudio onApiReady={(event) => { apiRef.current = event.api; }} />

Each of the examples below builds on this.

Edit and view modes

Most people looking at a payments dashboard just want to read it; a few want to change it. AG Studio has an edit mode (the full builder, with the Data and Compose panels) and a view mode (the finished report, filters only), switched with a single prop:

export function Dashboard() {
  const [mode, setMode] = useState<AgStudioMode>('view');

  return (
    <>
      <button onClick={() => setMode(mode === 'edit' ? 'view' : 'edit')}>
        {mode === 'edit' ? 'Done' : 'Edit'}
      </button>

      <AgStudio mode={mode} /* ... */ />
    </>
  );
}

Because the mode is just state in your app, it's easy to tie into your existing permissions to only show the Edit button to users who are allowed to change reports.

Undo and redo

AG Studio tracks a history of every change, so undo and redo are one API call each. To enable and disable the buttons, read the history whenever the state changes:

export function Dashboard() {
  const apiRef = useRef<AgStudioApi | null>(null);
  const [history, setHistory] = useState({ canUndo: false, canRedo: false });

  return (
    <>
      <button disabled={!history.canUndo} onClick={() => apiRef.current?.undo()}>Undo</button>
      <button disabled={!history.canRedo} onClick={() => apiRef.current?.redo()}>Redo</button>

      <AgStudio
        onApiReady={(event) => { apiRef.current = event.api; }}
        onStateUpdated={(event) => {
          const { undo, redo } = event.api.getHistory();
          setHistory({ canUndo: undo.length > 0, canRedo: redo.length > 0 });
        }}
      />
    </>
  );
}

Saving and restoring reports

Because a report is just a serialisable object, saving one is a case of reading the state and putting it somewhere. Restoring is the reverse:

export function Dashboard({ reports }) {
  const apiRef = useRef<AgStudioApi | null>(null);

  const save = () => {
    const state = apiRef.current?.getState();
    if (state) localStorage.setItem('report-state', JSON.stringify(state));
  };

  const load = (report) => {
    const api = apiRef.current;
    if (!api) return;
    api.setState(report.state);
    // Undo shouldn't step back into the previous report
    api.clearHistory();
  };

  return (
    <>
      {reports.map((report) => (
        <button key={report.id} onClick={() => load(report)}>{report.name}</button>
      ))}
      <button onClick={save}>Save</button>
      <AgStudio onApiReady={(event) => { apiRef.current = event.api; }} />
    </>
  );
}

We used localStorage to keep the demo self-contained, but in production you'd send the state to your own API and store it per user (or per team) in your database. Studio doesn't care where it lives.

Changing pages

AG Studio leaves page navigation to the host app, so you can render pages as tabs, a sidebar, or anything else. Pages are part of the report state, so a couple of small, pure helpers are all we need:

// report-state.ts
export const selectPage = (state: AgReportState, pageId: string): AgReportState => ({
  ...state,
  selectedPageId: pageId,
});

// An empty page is just { id }
export const addPage = (state: AgReportState, pageId: string): AgReportState => ({
  ...state,
  pages: [...state.pages, { id: pageId }],
  selectedPageId: pageId,
});

To apply them, read the current state from the API, transform it, and set it back. Listening to onStateUpdated keeps our navigation in sync, whether a change came from our buttons, the user, or undo and redo:

export function Dashboard() {
  const apiRef = useRef<AgStudioApi | null>(null);
  const [state, setState] = useState<AgReportState>(dashboardReport);

  const updateState = (next: (current: AgReportState) => AgReportState) => {
    const api = apiRef.current;
    if (api) api.setState(next(api.getState()));
  };

  return (
    <>
      <nav>
        {state.pages.map((page, i) => (
          <button key={page.id} onClick={() => updateState((s) => selectPage(s, page.id))}>
            Page {i + 1}
          </button>
        ))}
        <button onClick={() => updateState((s) => addPage(s, crypto.randomUUID()))}>+ Add page</button>
      </nav>

      <AgStudio
        initialState={dashboardReport}
        onApiReady={(event) => { apiRef.current = event.api; }}
        onStateUpdated={(event) => setState(event.state)}
      />
    </>
  );
}

Put them all together, and you get the full experience: switching between reports, editing in place, saving changes, and adding new pages.

0:00
/1:42

With those four pieces in place, AG Studio's own chrome disappears into your app. The sidebar, header and buttons are all yours; Studio just handles the report.

Adding an AI assistant

For the final step, let's enable users to ask questions of their payments data - and have the answers turn up as widgets on the page. AG Studio's Agent Framework provides the chat panel and the tools an LLM needs to read and edit a report. You bring the model.

How the Agent Framework fits together

Before we get into the code, it's worth a quick look at what's going on under the hood. The framework is made up of four pieces:

  • UI: the chat panel your users interact with.
  • Harness: the orchestrator. It hands tools to the agents, and feeds their progress back up to the UI.
  • Agents: specialised prompts that each do one job well. Out of the box, there's a data agent that queries your data, and a page agent that places widgets on the page.
  • Tools: what allows the agents to actually do things, like running a query or adding a widget.
Diagram of the AG Studio agent framework as four connected stages (Chat UI, Harness, Agents, Tools), each with a dashed swap-out box beneath it for your own UI, loop, agents or tools, all connected to “Your LLM from any provider.”
Every layer of the agent framework is optional, so you can swap in your own UI, loop, agents or tools and run it on any LLM provider.

What's really cool is that every one of these is customisable. You can swap in your own agents, your own tools, or even your own harness, all powered by whichever LLM you choose. For this post, though, we'll stick with the out-of-the-box functionality, which keeps the setup pleasantly small.

There are three parts to wire up: an adapter that talks to your LLM, a harness that configures the assistant, and a server route that keeps your API key safe.

The adapter and harness

An adapter maps between AG Studio's AI types and your LLM provider's API. We used the OpenAI adapter from the AG Studio docs as-is, pointed at our own server rather than at OpenAI directly. The harness then sets up the assistant, including the prompt starters users see before they've typed anything:

// app/dashboard/studio/ai.ts
import { createAiHarness, type AgAiHarnessSetup } from 'ag-studio';
import { openaiAdapter } from './openai-adapter';

export const ai: AgAiHarnessSetup = ({ api }) =>
  createAiHarness(api, {
    // Posts to our own /api/ai/responses route - no key in the browser
    adapter: openaiAdapter({ endpoint: '/api/ai', model: 'gpt-5.4-mini' }),
    promptStarters: [
      {
        label: 'Overdue invoices',
        prompt: 'Which customers owe the most on overdue invoices?',
      },
      {
        label: 'Revenue by month',
        prompt: 'Add a chart of paid invoice revenue by month.',
      },
    ],
  });

Then register the AI module on the provider, and pass the harness to <AgStudio>. This is the same component as before, with just those two additions:

import { AgStudioAiModule } from 'ag-studio';
import { ai } from './studio/ai';

export function Dashboard({ data, licenseKey }: DashboardProps) {
  const studioData = useMemo(() => buildStudioData(data), [data]);

  return (
    <AgStudioProvider licenseKey={licenseKey} modules={[AgStudioAiModule]}>
      <AgStudio
        data={studioData}
        ai={ai}
        /* ...theme, initialState, mode */
      />
    </AgStudioProvider>
  );
}

Keeping the key on the server

The adapter runs in the browser, so it must never hold your OpenAI key. Instead, it posts to a small Next.js route that adds the key, forwards the request, and streams the reply straight back:

The version in the repo also restricts which models can be called and caps output tokens. If you deploy it publicly, add authentication and rate limiting too - otherwise anyone can spend your OpenAI credits.

// app/api/ai/responses/route.ts
export async function POST(request: Request) {
  const body = await request.json();

  const upstream = await fetch('https://api.openai.com/v1/responses', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.AG_STUDIO_OPENAI_API_KEY}`,
    },
    body: JSON.stringify(body),
    // Stopping the chat in Studio cancels this request too
    signal: request.signal,
  });

  // Stream the response back to the adapter, unchanged
  return new Response(upstream.body, {
    status: upstream.status,
    headers: {
      'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json',
    },
  });
}

With that in place, the cliffhanger is resolved. Start from a blank report and ask the assistant which customers owe the most on overdue invoices. You don’t need a chart, only an answer, so Studio spins up its data agent, runs a couple of queries, and replies inline like any other chat assistant.

Then take it a step further and ask it to plot the answer onto a report. It builds a chart and a table, spots an issue with its own work, and goes back to fix it. Hit save and the result works like any other report, whether a human or an AI built it.

0:00
/0:58

This is also where those description fields from the data model pay off. They're the context the agents use to understand what your data actually means - like the warning not to sum invoices across currencies.

OpenAI isn't a requirement, either. Swap the adapter, and the same harness works with whichever LLM provider (or self-hosted model) you prefer.

Bring your own API

Whilst everything in the demo has been focused on PayPal’s APIs, AG Studio works with any backend and the approach is the exact same. 

To point this at a different API:

  1. Fetch on the server. Keep credentials out of the browser; AG Studio only ever needs the rows.
  2. Flatten into tables. One array of plain objects per thing you want to report on. Split nested objects (line items, payments, addresses) into their own tables with an ID back to the parent.
  3. Clean up once. Convert strings to numbers, normalise currencies and dates, and pre-calculate anything that needs business rules.
  4. Describe the data. Add a source per table, friendly field names and formats, and a description for anything an LLM (or a new colleague) might misread.
  5. Join it up. Add relationships between tables, and connect every date field to a calendar.
  6. Add measures. Define ratios and rates as expressions, so they're calculated correctly after filtering.

From there, the component, theme, initial state, host-app controls and AI assistant carry over unchanged. And if your data is too big to send to the browser, AG Studio can run its data engine on your server instead.

Summary

In this post, we took raw payments data from a REST API and turned it into a multi-page, themed, self-serve dashboard with an AI assistant - without writing a single chart, filter or layout ourselves.

The effort went where it matters: shaping the data on the server, describing it once for AG Studio, and wiring Studio into the rest of the app. Everything users actually see on the dashboard is report state, which means it can be built, saved and changed by the people asking for it, rather than landing on your backlog.

We've only scratched the surface, too. A few things worth exploring next:

  • Custom widgets: Need a visualisation that's not included by default? Bring your own components and wire them into AG Studio's data engine.
  • Guardrails: Configure exactly what users can see and change, to fit your existing role-based permissions.
  • Server-side data: For datasets with millions of rows, run the data engine on your server instead.

What's next: the PayPal AI Hackathon

This post started life as a session for Build What's Next with PayPal and AI, a six-week hackathon run by PayPal, in which AG Grid is a partner. If you're reading this before submissions close on 12 November 2026, there's $10,000 up for grabs for the best use of AG Studio:

  • $5,000 for first place,
  • $2,000 for second, and
  • $1,000 each for three third-place winners.

Note that hackathon entries do not require a licence - for the judging period, we will take care of the licence key.

A lot of the hackathon's themes are about AI, so we're especially keen to see how far you can take the Agent Framework: custom agents, modified (or brand-new) tools, or creative ideas we haven't thought of yet.

The boilerplate repo is everything in this post, ready to clone. For the PayPal side - sandbox accounts, credentials and the APIs - read Eddie's post, and come and say hello on our Discord if you get stuck.

Get started

AG Studio is available now. Visit the AG Studio documentation to get started, or request a demo to see it in action with your data.

The AG Studio team are continuing to expand its capabilities, focusing on deeper AI integration, richer widget types, and broader accessibility.

If there's a specific capability you need that's not on our roadmap, we want to hear about it. Use the contact form, open a discussion on GitHub, or join our Discord. Enterprise customers can reach our engineering team directly via Zendesk.

Read more posts about...