{"slug": "build-a-payments-dashboard-without-building-a-dashboard", "title": "Build a payments dashboard without building a dashboard", "summary": "AG Grid published a step-by-step tutorial showing how its AG Studio embedded analytics component can generate a multi-page payments dashboard from PayPal REST API sandbox data without writing dashboard code, using a single `<AgStudio data={...} />` component that renders 30+ grid and chart widgets with drag-and-drop report building, global filters and cross-filtering. The tutorial keeps API credentials server-side by flattening every API response into plain rows before the browser sees them, and describes the data model as four objects — sources, relationships, expressions and calendars — plus an optional AI assistant built from one module, one LLM adapter and a small server-side proxy. AG Grid says nothing in the setup is PayPal-specific, and the full source is available in a PayPal + AG Studio boilerplate on GitHub.", "body_md": "Earlier this month, we ran a live session for the [__PayPal AI Hackathon__](https://paypalaihackathon.devpost.com/) 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__](https://www.youtube.com/watch?v=Eq8wzURH898).\n\nFor newcomers, [AG Studio](https://www.ag-grid.com/studio/javascript/overview/) 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:\n\nThis 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.\n\n#### TL;DR\n\n- The server fetches from the API and flattens every response into plain rows. Credentials never leave the server; the browser only ever sees rows.\n- 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).\n- `<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.\n- 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 & redo, and saved reports.\n- An AI assistant is one module, one adapter and a small server-side proxy away.\n\n#### **What we'll cover**\n\n1. **What we're building:** the finished dashboard and how it's put together.\n2. **Loading the data:** fetching from the API and flattening it into rows on the server.\n3. **Defining the data model:** sources, relationships, expressions and calendars.\n4. **Rendering AG Studio:** from data to drag-and-drop report builder.\n5. **Theming:** making Studio look like part of your app.\n6. **Pre-building reports:** shipping a ready-made report as the initial state.\n7. **Connecting Studio to your UI:** edit & view modes, undo & redo, saving reports and changing pages.\n8. **Adding an AI assistant:** the Agent Framework, an LLM adapter and a server-side proxy.\n9. **Bring your own API:** applying all of this to data that isn't from PayPal.\n\nWhilst 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.\n\n*Prefer code to prose? The full source is in the* __PayPal + AG Studio boilerplate__ *on GitHub.*\n\n## **What we're building**\n\nThe 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 reports. Users can switch between these reports, edit them, and undo their changes.\n\nThere 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.\n\nUsers 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.\n\n*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.*\n\n## **A look at the architecture**\n\nThe app is a Next.js project, and the work splits cleanly into three places:\n\n1. **External APIs.** The PayPal REST API for invoices, catalogue products and plans, transactions and balances - plus OpenAI for the assistant.\n2. **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.\n3. **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.\n\nThe 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.\n\nThe 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 Studio reports every change back through the onStateUpdated event. Every example that follows in this post uses this pattern.\n\n## **Loading the data**\n\nAG 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):\n\n``` js\n// app/dashboard/page.tsx\n\nimport { connection } from 'next/server';\nimport { getDashboardData } from '@/lib/dashboard-data';\nimport { Dashboard } from './dashboard';\n\nexport default async function DashboardPage() {\n  // Live data: render per request, not at build time.\n  await connection();\n\n  // Calls the PayPal REST API, then turns every response into flat rows\n  const data = await getDashboardData();\n\n  // Pass the rows to the client component, which renders AG Studio\n  return <Dashboard data={data} licenseKey={process.env.AG_STUDIO} />;\n}\n```\n\n`getDashboardData()` calls five endpoints in parallel and returns one array per \"table\" we want to report on:\n\n```\n// lib/dashboard-data.ts\n\nexport async function getDashboardData(asOf = new Date()) {\n  const [products, plans, invoices, transactions, balances] = await Promise.all([\n    listProducts(),\n    listPlans(),\n    listInvoices(),\n    searchTransactions(/* last 31 days */),\n    getBalances(),\n  ]);\n  const today = asOf.toISOString().slice(0, 10);\n\n  return {\n    asOf: asOf.toISOString(),\n    customers: customerRows(invoices),\n    invoices: invoiceRows(invoices, today),\n    invoiceItems: invoiceItemRows(invoices),\n    invoicePayments: invoicePaymentRows(invoices),\n    products: productRows(products),\n    plans: planRows(plans),\n    transactions: transactionRows(transactions),\n    payouts: payoutRows(transactions),\n    balances: balanceRows(balances),\n  };\n}\n```\n\n*The row mappers have been left out for brevity - they're in the* __repository__ *if you're curious.*\n\nNotice 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.\n\nIt's also the right place to do any domain-specific clean-up. A few examples from this project:\n\n- PayPal amounts are decimal strings with a currency `({ value: \"2.99\", currency_code: \"USD\" })` , so they're converted to numbers.\n- Derived values that need business rules, like `daysOverdue` or an aging bucket, are calculated once on the server, rather than by every widget.\n- Refunds get a negative `signedAmount` , so users can sum it to get net cash.\n\n*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.*\n\nFinally, 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.\n\n## **Defining the data model**\n\nWith 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.\n\nThere'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.\n\n### **Sources**\n\nEach 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:\n\n``` python\n// app/dashboard/studio/data.ts\n\nimport type { AgDataSourcesDefinition } from 'ag-studio';\nimport type { DashboardData } from '@/lib/dashboard-data';\n\nexport function buildStudioData(data: DashboardData): AgDataSourcesDefinition {\n  return {\n    description: 'PayPal sandbox data for a merchant that sells subscription products and sends invoices. ' +\n      'Invoices are in USD, EUR or GBP and are not converted, so filter by currency before summing money.',\n    sources: [\n      {\n        id: 'invoices',\n        description: 'One row per invoice.',\n        data: data.invoices,\n        fields: [\n          { id: 'id', name: 'Invoice ID', format: 'textFormat', hide: true },\n          { id: 'number', name: 'Invoice', format: 'textFormat' },\n          { id: 'customerId', name: 'Customer ID', format: 'textFormat', hide: true },\n          { id: 'invoiceDate', name: 'Invoice Date', format: 'dateFormat' },\n          { id: 'issuedAmount', name: 'Invoiced', format: 'currencyFormat' },\n          { id: 'paidAmount', name: 'Paid', format: 'currencyFormat' },\n          // ...additional fields\n        ],\n      },\n      // ...customers, invoiceItems, invoicePayments, products, plans, transactions, payouts, balances\n    ],\n  };\n}\n```\n\nThen it's just a case of rinse and repeat for every table. A few things worth calling out:\n\n- **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.\n- **format** tells AG Studio how to display and aggregate the field: currencies, percentages, integers, dates and so on.\n- **hide** keeps a field out of the UI whilst still letting Studio use it - perfect for IDs that only exist to join tables together.\n- **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.\n\n### **Relationships**\n\nRemember 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:\n\n```\nexport function buildStudioData(data: DashboardData): AgDataSourcesDefinition {\n  return {\n    sources: [ /* ... */ ],\n    relationships: [\n      // Each invoice belongs to a single customer\n      {\n        id: 'invoice-customer',\n        type: 'many-to-one',\n        source: { tableId: 'invoices', fieldId: 'customerId' },\n        target: { tableId: 'customers', fieldId: 'id' },\n      },\n      // Each line item belongs to a single invoice\n      {\n        id: 'item-invoice',\n        type: 'many-to-one',\n        source: { tableId: 'invoiceItems', fieldId: 'invoiceId' },\n        target: { tableId: 'invoices', fieldId: 'id' },\n      },\n      // ...payments -> invoices, plans -> products\n    ],\n  };\n}\n```\n\nNow 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.\n\n### **Expressions**\n\nSome 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.\n\nExpressions solve this by defining calculated fields that Studio evaluates *after* filtering and aggregation:\n\n```\nexport function buildStudioData(data: DashboardData): AgDataSourcesDefinition {\n  return {\n    sources: [ /* ... */ ],\n    relationships: [ /* ... */ ],\n    expressions: [\n      {\n        id: 'collectionRate',\n        name: 'Collection Rate',\n        description: 'Paid / invoiced. Filter to one currency first.',\n        format: 'percentageFormat',\n        isMeasure: true, // true = a single aggregated value, false = a new column\n        expression: {\n          operator: 'divide',\n          inputs: [\n            { id: 'invoices.paidAmount', aggregation: 'sum' },\n            { id: 'invoices.issuedAmount', aggregation: 'sum' },\n          ],\n        },\n      },\n      // ...overdueShare, refundRate\n    ],\n  };\n}\n```\n\nBecause 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.\n\n### **Calendars**\n\nFinally, 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.\n\nA calendar defines those groupings once, over the full date range in the data:\n\n```\nexport function buildStudioData(data: DashboardData): AgDataSourcesDefinition {\n  // Every date in the data, so the calendar covers exactly that range\n  const dates = [\n    ...data.invoices.map((r) => r.invoiceDate),\n    ...data.invoicePayments.map((r) => r.date),\n  ].filter(Boolean).sort();\n\n  return {\n    sources: [ /* ... */ ],\n    expressions: [ /* ... */ ],\n    calendars: [\n      {\n        id: 'calendar',\n        label: 'Calendar',\n        range: {\n          from: { type: 'date', value: dates[0] },\n          to: { type: 'date', value: dates.at(-1) },\n        },\n        fragments: ['year', 'quarter', 'month', 'week', 'day', 'dayOfWeek'],\n      },\n    ],\n    relationships: [\n      /* ...table relationships */\n      // Connect each date field to the calendar\n      { id: 'invoices-invoiceDate-calendar', source: { tableId: 'invoices', fieldId: 'invoiceDate' }, target: { calendarId: 'calendar' } },\n      { id: 'invoicePayments-date-calendar', source: { tableId: 'invoicePayments', fieldId: 'date' }, target: { calendarId: 'calendar' } },\n      // Date-time fields can be truncated to the day\n      { id: 'transactions-date-calendar', source: { tableId: 'transactions', fieldId: 'date' }, target: { calendarId: 'calendar' }, truncate: 'day' },\n    ],\n  };\n}\n```\n\nThe 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.\n\n*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.*\n\n## **Rendering AG Studio**\n\nWith 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:\n\n``` js\n// app/dashboard/dashboard.tsx\n'use client';\n\nimport { useMemo } from 'react';\nimport { AgStudio, AgStudioProvider } from 'ag-studio-react';\nimport type { DashboardData } from '@/lib/dashboard-data';\nimport { buildStudioData } from './studio/data';\n\nexport function Dashboard({ data, licenseKey }: { data: DashboardData; licenseKey?: string }) {\n  // Only rebuild the data definition when the rows change\n  const studioData = useMemo(() => buildStudioData(data), [data]);\n\n  return (\n    <AgStudioProvider licenseKey={licenseKey}>\n      <AgStudio data={studioData} style={{ height: '100%', width: '100%' }} />\n    </AgStudioProvider>\n  );\n}\n```\n\nAnd that's it. Not a single widget, filter or layout has been written, but we already have a working report builder:\n\n- **Data:** every field from all nine tables, with friendly names, ready to drag onto the canvas.\n- **Compose:** 30+ AG Grid and AG Charts widgets - grids, KPIs, bar, line, combo, gauge, sunburst and more.\n- **Filters:** global and page-level filters on any field, plus cross-filtering between widgets.\n\nTry 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.\n\nIf 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?\n\n## **Theming**\n\nAG 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`:\n\n``` js\n// app/dashboard/studio/theme.ts\n\nimport { studioTheme } from 'ag-studio';\n\nexport const agStudioPayPalTheme = studioTheme.withParams({\n  // Shared by Studio's own UI, grids and charts\n  fontFamily: 'var(--pp-font)',\n  textColor: 'var(--pp-ink)',\n  backgroundColor: 'var(--pp-surface)',\n  borderColor: 'var(--pp-border)',\n  accentColor: 'var(--pp-blue)',\n\n  // Widgets match the rest of the app's cards\n  studioWidgetBorderRadius: 8,\n  studioWidgetTitleTextColor: 'var(--pp-navy)',\n\n  // Grids and charts\n  gridHeaderTextColor: 'var(--pp-muted)',\n  gridRowHeight: 40,\n  chartPaletteFills1Color: 'var(--pp-chart-1)',\n  chartPaletteFills2Color: 'var(--pp-chart-2)',\n\n  // ...other theme parameters\n});\n```\n\nThen pass it to the component:\n\n`<AgStudio data={studioData} theme={agStudioPayPalTheme} />`\n\n**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.\n\nThe 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__](https://github.com/paypaldev/hackathon-paypal-ag-grid-boilerplate/blob/main/app/dashboard/studio/theme.ts) is a good reference if you want to see how far it goes.\n\nWith 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**](https://www.ag-grid.com/studio/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. \n\n## **Giving users a place to start**\n\nA 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.\n\nEvery 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:\n\n``` python\nimport type { AgPageState, AgReportState } from 'ag-studio';\n\nconst page: AgPageState = {\n  id: 'page-id',\n  widgets: { /* what each widget shows */ },\n  widgetLayout: { /* where each widget sits on the canvas */ },\n  filter: { /* filters applied to the whole page */ },\n};\n\nexport const dashboardReport: AgReportState = {\n  selectedPageId: 'page-id',\n  pages: [page],\n};\n```\n\nHere's a real example: the grid of open invoices from the \"Receivables\" page, along with its layout and page-level filters:\n\n``` js\n// app/dashboard/studio/initial-report.ts\nconst receivables: AgPageState = {\n  id: 'receivables',\n  widgets: {\n    'open-invoices': {\n      type: 'grid',\n      dataMapping: {\n        cols: [\n          { id: 'invoices.number' },\n          { id: 'customers.name' }, // from a related table\n          { id: 'invoices.dueDate' },\n          { id: 'invoices.daysOverdue' },\n          { id: 'invoices.outstanding', aggregation: 'sum' },\n        ],\n      },\n      sort: [{ field: { id: 'invoices.daysOverdue' }, direction: 'desc' }],\n      format: { title: { enabled: true, text: 'Open invoices, most overdue first' } },\n    },\n    // ...KPIs, an aging chart, a currency filter\n  },\n  widgetLayout: {\n    // Positions on a 24-column grid: x, y, width, height\n    'open-invoices': { xTrack: 0, yTrack: 25, xSpan: 24, ySpan: 20 },\n    // ...more widgets\n  },\n  filter: {\n    page: [\n      { field: { id: 'invoices.currency' }, model: { operator: 'isIn', value: ['USD'] } },\n      { field: { id: 'invoices.isReceivable' }, model: { operator: 'isTrue' } },\n    ],\n  },\n};\n\nexport const dashboardReport: AgReportState = {\n  selectedPageId: 'revenue',\n  pages: [revenue, receivables, cash, catalogue],\n};\n```\n\nEverything 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.\n\nWe'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.\n\nThen pass the report to `<AgStudio>` as its `initialState`:\n\n``` js\nimport { dashboardReport } from './studio/initial-report';\n\nexport function Dashboard({ data, licenseKey }: DashboardProps) {\n  const studioData = useMemo(() => buildStudioData(data), [data]);\n\n  return (\n    <AgStudioProvider licenseKey={licenseKey}>\n      <AgStudio\n        data={studioData}\n        theme={agStudioPayPalTheme}\n        initialState={dashboardReport}\n      />\n    </AgStudioProvider>\n  );\n}\n```\n\nNow 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:\n\nAs 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.\n\n*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.*\n\n## **Connecting Studio to your UI**\n\nSo 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.\n\nTo get hold of the API, store it in a `ref` when AG Studio is ready:\n\n``` js\nconst apiRef = useRef<AgStudioApi | null>(null);\n\n<AgStudio onApiReady={(event) => { apiRef.current = event.api; }} />\n```\n\nEach of the examples below builds on this.\n\n### **Edit and view modes**\n\nMost 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:\n\n```\nexport function Dashboard() {\n  const [mode, setMode] = useState<AgStudioMode>('view');\n\n  return (\n    <>\n      <button onClick={() => setMode(mode === 'edit' ? 'view' : 'edit')}>\n        {mode === 'edit' ? 'Done' : 'Edit'}\n      </button>\n\n      <AgStudio mode={mode} /* ... */ />\n    </>\n  );\n}\n```\n\nBecause 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.\n\n### **Undo and redo**\n\nAG 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:\n\n``` js\nexport function Dashboard() {\n  const apiRef = useRef<AgStudioApi | null>(null);\n  const [history, setHistory] = useState({ canUndo: false, canRedo: false });\n\n  return (\n    <>\n      <button disabled={!history.canUndo} onClick={() => apiRef.current?.undo()}>Undo</button>\n      <button disabled={!history.canRedo} onClick={() => apiRef.current?.redo()}>Redo</button>\n\n      <AgStudio\n        onApiReady={(event) => { apiRef.current = event.api; }}\n        onStateUpdated={(event) => {\n          const { undo, redo } = event.api.getHistory();\n          setHistory({ canUndo: undo.length > 0, canRedo: redo.length > 0 });\n        }}\n      />\n    </>\n  );\n}\n```\n\n### **Saving and restoring reports**\n\nBecause a report is just a serialisable object, saving one is a case of reading the state and putting it somewhere. Restoring is the reverse:\n\n``` js\nexport function Dashboard({ reports }) {\n  const apiRef = useRef<AgStudioApi | null>(null);\n\n  const save = () => {\n    const state = apiRef.current?.getState();\n    if (state) localStorage.setItem('report-state', JSON.stringify(state));\n  };\n\n  const load = (report) => {\n    const api = apiRef.current;\n    if (!api) return;\n    api.setState(report.state);\n    // Undo shouldn't step back into the previous report\n    api.clearHistory();\n  };\n\n  return (\n    <>\n      {reports.map((report) => (\n        <button key={report.id} onClick={() => load(report)}>{report.name}</button>\n      ))}\n      <button onClick={save}>Save</button>\n      <AgStudio onApiReady={(event) => { apiRef.current = event.api; }} />\n    </>\n  );\n}\n```\n\nWe 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.\n\n### **Changing pages**\n\nAG 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:\n\n``` js\n// report-state.ts\nexport const selectPage = (state: AgReportState, pageId: string): AgReportState => ({\n  ...state,\n  selectedPageId: pageId,\n});\n\n// An empty page is just { id }\nexport const addPage = (state: AgReportState, pageId: string): AgReportState => ({\n  ...state,\n  pages: [...state.pages, { id: pageId }],\n  selectedPageId: pageId,\n});\n```\n\nTo 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:\n\n``` js\nexport function Dashboard() {\n  const apiRef = useRef<AgStudioApi | null>(null);\n  const [state, setState] = useState<AgReportState>(dashboardReport);\n\n  const updateState = (next: (current: AgReportState) => AgReportState) => {\n    const api = apiRef.current;\n    if (api) api.setState(next(api.getState()));\n  };\n\n  return (\n    <>\n      <nav>\n        {state.pages.map((page, i) => (\n          <button key={page.id} onClick={() => updateState((s) => selectPage(s, page.id))}>\n            Page {i + 1}\n          </button>\n        ))}\n        <button onClick={() => updateState((s) => addPage(s, crypto.randomUUID()))}>+ Add page</button>\n      </nav>\n\n      <AgStudio\n        initialState={dashboardReport}\n        onApiReady={(event) => { apiRef.current = event.api; }}\n        onStateUpdated={(event) => setState(event.state)}\n      />\n    </>\n  );\n}\n```\n\nPut them all together, and you get the full experience: switching between reports, editing in place, saving changes, and adding new pages.\n\nWith 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.\n\n## **Adding an AI assistant**\n\nFor 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__](https://ag-grid.com/studio/r/ai/) provides the chat panel and the tools an LLM needs to read and edit a report. You bring the model.\n\n### **How the Agent Framework fits together**\n\nBefore 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:\n\n- **UI:** the chat panel your users interact with.\n- **Harness:** the orchestrator. It hands tools to the agents, and feeds their progress back up to the UI.\n- **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.\n- **Tools:** what allows the agents to actually*do* things, like running a query or adding a widget.\n\nWhat'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.\n\nThere 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.\n\n### **The adapter and harness**\n\nAn 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:\n\n``` js\n// app/dashboard/studio/ai.ts\nimport { createAiHarness, type AgAiHarnessSetup } from 'ag-studio';\nimport { openaiAdapter } from './openai-adapter';\n\nexport const ai: AgAiHarnessSetup = ({ api }) =>\n  createAiHarness(api, {\n    // Posts to our own /api/ai/responses route - no key in the browser\n    adapter: openaiAdapter({ endpoint: '/api/ai', model: 'gpt-5.4-mini' }),\n    promptStarters: [\n      {\n        label: 'Overdue invoices',\n        prompt: 'Which customers owe the most on overdue invoices?',\n      },\n      {\n        label: 'Revenue by month',\n        prompt: 'Add a chart of paid invoice revenue by month.',\n      },\n    ],\n  });\n```\n\nThen 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:\n\n``` js\nimport { AgStudioAiModule } from 'ag-studio';\nimport { ai } from './studio/ai';\n\nexport function Dashboard({ data, licenseKey }: DashboardProps) {\n  const studioData = useMemo(() => buildStudioData(data), [data]);\n\n  return (\n    <AgStudioProvider licenseKey={licenseKey} modules={[AgStudioAiModule]}>\n      <AgStudio\n        data={studioData}\n        ai={ai}\n        /* ...theme, initialState, mode */\n      />\n    </AgStudioProvider>\n  );\n}\n```\n\n### **Keeping the key on the server**\n\nThe 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:\n\n*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.*\n\n```\n// app/api/ai/responses/route.ts\nexport async function POST(request: Request) {\n  const body = await request.json();\n\n  const upstream = await fetch('https://api.openai.com/v1/responses', {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      Authorization: `Bearer ${process.env.AG_STUDIO_OPENAI_API_KEY}`,\n    },\n    body: JSON.stringify(body),\n    // Stopping the chat in Studio cancels this request too\n    signal: request.signal,\n  });\n\n  // Stream the response back to the adapter, unchanged\n  return new Response(upstream.body, {\n    status: upstream.status,\n    headers: {\n      'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json',\n    },\n  });\n}\n```\n\nWith 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.\n\nThen 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.\n\nThis 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.\n\nOpenAI isn't a requirement, either. Swap the adapter, and the same harness works with whichever LLM provider (or self-hosted model) you prefer.\n\n## **Bring your own API**\n\nWhilst everything in the demo has been focused on PayPal’s APIs, AG Studio works with any backend and the approach is the exact same.\n\nTo point this at a different API:\n\n1. **Fetch on the server.** Keep credentials out of the browser; AG Studio only ever needs the rows.\n2. **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.\n3. **Clean up once.** Convert strings to numbers, normalise currencies and dates, and pre-calculate anything that needs business rules.\n4. **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.\n5. **Join it up.** Add relationships between tables, and connect every date field to a calendar.\n6. **Add measures.** Define ratios and rates as expressions, so they're calculated correctly after filtering.\n\nFrom 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__](https://ag-grid.com/studio/r/server-side-data/) instead.\n\n## **Summary**\n\nIn 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.\n\nThe 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.\n\nWe've only scratched the surface, too. A few things worth exploring next:\n\n- [__Custom widgets__](https://ag-grid.com/studio/r/custom-widgets/) : Need a visualisation that's not included by default? Bring your own components and wire them into AG Studio's data engine.\n- [__Guardrails__](https://ag-grid.com/studio/r/modes-layout/) : Configure exactly what users can see and change, to fit your existing role-based permissions.\n- [__Server-side data__](https://ag-grid.com/studio/r/server-side-data/) : For datasets with millions of rows, run the data engine on your server instead.\n\n## **What's next: the PayPal AI Hackathon**\n\nThis post started life as a session for [__Build What's Next with PayPal and AI__](https://paypalaihackathon.devpost.com/), 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:\n\n- $5,000 for first place,\n- $2,000 for second, and\n- $1,000 each for three third-place winners.\n\nNote that hackathon entries do not require a licence - for the judging period, we will take care of the licence key.\n\nA 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.\n\nThe [__boilerplate repo__](https://github.com/paypaldev/hackathon-paypal-ag-grid-boilerplate) 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__](https://discord.gg/NtXRduFYp) if you get stuck.\n\n## What's next\n\nThe AG Studio team are continuing to expand its capabilities, focusing on deeper AI integration, richer widget types, and broader accessibility.\n\nIf there's a specific capability you need that's not on our roadmap, we want to hear about it. Use the [contact form](https://www.ag-grid.com/about/), open a discussion on [GitHub](https://github.com/ag-grid/ag-grid/issues), or join our [Discord](https://discord.gg/NtXRduFYp). Enterprise customers can reach our engineering team directly via [Zendesk](https://ag-grid.zendesk.com/).\n\n## Get started\n\nAG Studio is available now. Visit the [AG Studio documentation](https://www.ag-grid.com/studio/r/overview/) to get started, or request a demo to see it in action with your data.", "url": "https://wpnews.pro/news/build-a-payments-dashboard-without-building-a-dashboard", "canonical_source": "https://www.ag-grid.com/blog/build-a-payments-dashboard-without-building-a-dashboard/", "published_at": "2026-11-06 16:01:06+00:00", "updated_at": "2026-11-06 16:23:17.455550+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "developer-tools", "ai-products"], "entities": ["AG Studio", "AG Grid", "AG Charts", "PayPal", "PayPal REST API", "PayPal AI Hackathon", "Eddie Jaoude", "GitHub"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/build-a-payments-dashboard-without-building-a-dashboard", "markdown": "https://wpnews.pro/news/build-a-payments-dashboard-without-building-a-dashboard.md", "text": "https://wpnews.pro/news/build-a-payments-dashboard-without-building-a-dashboard.txt", "jsonld": "https://wpnews.pro/news/build-a-payments-dashboard-without-building-a-dashboard.jsonld"}}