> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gavai.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Query your data

> Use runQuery to power Tables, DetailPanels, and KanbanBoards.

export const ErrorTable = ({errors}) => <div className="gv-error-table" role="table" aria-label="Errors and fixes">
    <div className="gv-error-table__head" role="row">
      <span className="gv-error-table__col-head" role="columnheader">Error</span>
      <span className="gv-error-table__col-head" role="columnheader">Cause</span>
      <span className="gv-error-table__col-head" role="columnheader">Fix</span>
    </div>
    {errors.map((err, i) => <div className="gv-error-table__row" role="row" key={i}>
        <code className="gv-error-table__code" role="cell">{err.code}</code>
        <div className="gv-error-table__cause" role="cell">{err.cause}</div>
        <div className="gv-error-table__fix" role="cell">{err.fix}</div>
      </div>)}
  </div>;

export const ExpectedOutput = ({caption = "Expected output", children}) => <div className="gv-output">
    <div className="gv-output__label">
      <svg className="gv-output__icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
        <polyline points="15 10 20 15 15 20" />
        <path d="M4 4v7a4 4 0 0 0 4 4h12" />
      </svg>
      <span>{caption}</span>
    </div>
    <div className="gv-output__body">{children}</div>
  </div>;

export const PageMeta = ({audience, readingTime, prereqs, level}) => {
  const items = [audience && ({
    key: "audience",
    label: "AUDIENCE",
    value: audience
  }), readingTime && ({
    key: "time",
    label: "READ",
    value: readingTime
  }), level && ({
    key: "level",
    label: "LEVEL",
    value: level
  }), prereqs && prereqs.length > 0 && ({
    key: "prereqs",
    label: "PREREQS",
    value: prereqs.join(" · ")
  })].filter(Boolean);
  if (items.length === 0) return null;
  return <div className="gv-page-meta" role="group" aria-label="Page metadata">
      {items.map(item => <div className="gv-page-meta__item" key={item.key}>
          <span className="gv-page-meta__label">{item.label}</span>
          <span className="gv-page-meta__value">{item.value}</span>
        </div>)}
    </div>;
};

export const BrandCard = ({eyebrow, title, href, icon, badge, cta, featured = false, horizontal = false, children}) => {
  const classes = ["gv-card", featured && "gv-card--featured", horizontal && "gv-card--horizontal"].filter(Boolean).join(" ");
  const inner = <>
      {icon && <span className="gv-card__icon" aria-hidden="true">
          <Icon icon={icon} />
        </span>}
      <div className="gv-card__main">
        {(eyebrow || badge) && <div className="gv-card__meta">
            {eyebrow && <Eyebrow label={eyebrow} tone="muted" />}
            {badge && <span className="gv-card__badge">{badge}</span>}
          </div>}
        <h3 className="gv-card__title">{title}</h3>
        {children && <div className="gv-card__body">{children}</div>}
        {cta && <span className="gv-card__cta">
            {cta}
            <svg className="gv-card__cta-arrow" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <line x1="7" y1="17" x2="17" y2="7" />
              <polyline points="7 7 17 7 17 17" />
            </svg>
          </span>}
      </div>
    </>;
  return href ? <a className={classes} href={href}>
      {inner}
    </a> : <div className={classes}>{inner}</div>;
};

export const NumberedStep = ({n, title, children}) => <li className="gv-step">
    <span className="gv-step__index">{n}</span>
    <div className="gv-step__body">
      <h3 className="gv-step__title">{title}</h3>
      <div className="gv-step__content">{children}</div>
    </div>
  </li>;

export const NumberedSteps = ({children}) => <ol className="gv-steps">{children}</ol>;

export const ArrowItem = ({children}) => <li className="gv-arrow-item">
    <span className="gv-arrow-item__arrow" aria-hidden="true">{"↳︎"}</span>
    <span className="gv-arrow-item__content">{children}</span>
  </li>;

export const ArrowList = ({children}) => <ul className="gv-arrow-list">{children}</ul>;

export const Eyebrow = ({label, tone = "default"}) => <div className={`gv-eyebrow gv-eyebrow--${tone}`}>
    <span className="gv-eyebrow__bar" aria-hidden="true" />
    <span className="gv-eyebrow__label">{label}</span>
  </div>;

<Eyebrow label="GUIDE" />

<PageMeta audience="Developers" readingTime="12 min" level="Intermediate" prereqs={["gavAI workspace", "Schema with at least one table"]} />

By the end of this guide your page reads typed, paginated rows from your workspace Postgres schema through the `runQuery` capability — with row-level access enforced automatically and no SQL written. You will declare a table, wire a **Table** block, enable sorting and filtering, connect click-through to a **DetailPanel**, and call `runQuery` directly for custom data fetching.

## Prerequisites

<ArrowList>
  <ArrowItem>A gavAI workspace.</ArrowItem>
  <ArrowItem>A schema with at least one table. Create one in the Builder under **Workspace → Schema**.</ArrowItem>
</ArrowList>

## Steps

<NumberedSteps>
  <NumberedStep title="Declare a table">
    Define the table in the workspace schema. The schema is a JSON document that lives at the workspace level — the Builder exposes it under **Workspace → Schema**. A minimal `customers` table:

    ```json theme={null}
    {
      "schema": {
        "customers": {
          "fields": {
            "name":       { "type": "text" },
            "email":      { "type": "text" },
            "status":     { "type": "text" },
            "created_at": { "type": "timestamp" }
          }
        }
      }
    }
    ```

    Once saved, every block on every page in the workspace can reference `"schema_table": "customers"`. The platform validates queries against this schema at render time.
  </NumberedStep>

  <NumberedStep title="Add a Table block">
    Add a **Table** block and point it at the table:

    ```json theme={null}
    {
      "block": "Table",
      "props": {
        "schema_table": "customers",
        "columns": ["name", "email", "status", "created_at"]
      }
    }
    ```

    The block fetches rows when the page loads. Column order follows the `columns` array; field names must match the schema exactly.
  </NumberedStep>

  <NumberedStep title="Enable sorting, filtering, and pagination">
    Add `sortable`, `filterable`, and `page_size`:

    ```json theme={null}
    {
      "block": "Table",
      "props": {
        "schema_table": "customers",
        "columns": ["name", "email", "status", "created_at"],
        "sortable": true,
        "filterable": true,
        "page_size": 25
      }
    }
    ```

    With `sortable: true`, column headers become click-to-sort. With `filterable: true`, a filter bar appears above the table. `page_size` controls rows per page; users navigate with the built-in controls.
  </NumberedStep>

  <NumberedStep title="Wire click-to-detail">
    Add `on_row_click` to navigate to a detail page when a user clicks a row. The `{{id}}` token resolves to the clicked row's primary key:

    ```json theme={null}
    {
      "block": "Table",
      "props": {
        "schema_table": "customers",
        "columns": ["name", "email", "status", "created_at"],
        "sortable": true,
        "filterable": true,
        "page_size": 25,
        "on_row_click": { "navigate": "/customers/{{id}}" }
      }
    }
    ```

    On the destination page (`/customers/[id]`), add a **DetailPanel** block with `record_id_param: "id"`. The panel reads the URL parameter and loads the matching record. See [DetailPanel](/pageblocks/blocks/detail-panel) for the full prop reference.
  </NumberedStep>

  <NumberedStep title="Call runQuery directly">
    When the built-in Table binding is not enough, invoke `runQuery` from a capability hook on the page. The full input shape:

    ```json theme={null}
    {
      "table": "customers",
      "filter": { "status": "active" },
      "sort": [{ "field": "created_at", "dir": "desc" }],
      "limit": 50,
      "cursor": null
    }
    ```

    <ExpectedOutput caption="runQuery response">
      ```json theme={null}
      {
        "rows": [
          { "name": "Ada Lovelace", "email": "ada@example.com", "status": "active", "created_at": "2026-01-15T09:00:00Z" }
        ],
        "next_cursor": "eyJpZCI6MTIzfQ==",
        "total": 312
      }
      ```
    </ExpectedOutput>

    Pass `next_cursor` back as `cursor` on the next call to page through results. The `limit` maximum is **1000 rows per call**. Omit `total` from the input if you do not need a count — computing it costs a second query.

    <Warning>
      `runQuery` runs in the security context of the calling page. The active `authUser` determines which rows are returnable. For server-side scripts and automation, use the [public REST API](/api-reference/introduction); `runQuery` is designed for in-page data fetching.
    </Warning>

    <Note>
      Whether row visibility for anonymous users is controlled by a row-level attribute (such as a `public` field) or a schema-level grant is not specified in the public source. Confirm with the platform team before relying on either behavior.
    </Note>
  </NumberedStep>
</NumberedSteps>

## Troubleshooting

<ErrorTable
  errors={[
{
  code: "Table renders empty for some users",
  cause: "Row-level access rules filtered the rows out.",
  fix: "Check the active authUser and the schema's row-level rules.",
},
{
  code: "field not found",
  cause: "A columns entry does not match a schema field name (case-sensitive).",
  fix: "Confirm the spelling and case against the schema definition.",
},
{
  code: "limit_exceeded",
  cause: "You passed limit greater than 1000.",
  fix: "Lower limit to ≤1000 and page through results with cursor.",
},
{
  code: "DetailPanel shows 'not found'",
  cause: "record_id_param does not match the URL parameter name.",
  fix: "The route /customers/[id] requires record_id_param: 'id'.",
},
]}
/>

## What you built

You declared a `customers` table, wired a **Table** block with sort, filter, and pagination, connected row clicks to a **DetailPanel** via URL parameters, and called `runQuery` directly for a custom fetch. Row-level access is enforced automatically — the page only returns rows the current user is allowed to see.

## Next steps

<Columns cols={2}>
  <BrandCard title="runQuery reference" href="/capabilities/run-query" cta="Read reference">
    Full input/output schema, filter DSL, pagination, and security context for runQuery.
  </BrandCard>

  <BrandCard title="Table block reference" href="/pageblocks/blocks/table" cta="Read reference">
    Every prop the Table block accepts, including column configuration and action hooks.
  </BrandCard>

  <BrandCard title="KanbanBoard block reference" href="/pageblocks/blocks/kanban-board" cta="Read reference">
    Group records by a status field and let users drag cards between columns.
  </BrandCard>
</Columns>
