> ## 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.

# Accept file uploads

> Let end-users upload files via the storeFile capability.

export const FeatureTable = ({columns, rows, featuredRow, caption, variant}) => <div className={`gv-feature-table ${variant ? `gv-feature-table--${variant}` : ""}`}>
    <table>
      {caption && <caption className="gv-feature-table__caption">{caption}</caption>}
      <thead>
        <tr>
          {columns.map(col => <th key={col.key} scope="col">
              <span className="gv-feature-table__header">{col.label}</span>
            </th>)}
        </tr>
      </thead>
      <tbody>
        {rows.map((row, i) => {
  const rowClass = [i === featuredRow ? "gv-feature-table__row--featured" : "", row.chosen ? "gv-feature-table__row--chosen" : ""].filter(Boolean).join(" ");
  return <tr key={i} className={rowClass}>
              {columns.map(col => {
    const v = row[col.key];
    if (variant === "tradeoff" && Array.isArray(v)) {
      const pill = col.key === "pros" ? "gv-feature-table__pill--pro" : col.key === "cons" ? "gv-feature-table__pill--con" : "";
      return <td key={col.key}>
                      {v.map((item, j) => <span key={j} className={`gv-feature-table__pill ${pill}`}>{item}</span>)}
                    </td>;
    }
    return <td key={col.key}>{v}</td>;
  })}
            </tr>;
})}
      </tbody>
    </table>
  </div>;

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 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={["Schema table with a string URL field", "Page in draft"]} />

By the end of this guide your page has a form with a file input. When a user uploads, `storeFile` writes the file to your workspace's object-store bucket and returns a URL; a chained `runQuery` persists that URL on the user's record. Storage is auto-provisioned — no buckets to create.

## Prerequisites

<ArrowList>
  <ArrowItem>A schema table with a string field for the URL — for example `customers.avatar_url`.</ArrowItem>
  <ArrowItem>A page in draft. You will add a `Form` block to it in step 2.</ArrowItem>
</ArrowList>

## Steps

<NumberedSteps>
  <NumberedStep title="Add the URL field to your schema">
    The page document's `schema` section declares which tables and fields the page can read and write. Add a string field to hold the uploaded file URL.

    ```json theme={null}
    {
      "version": "v0.4",
      "metadata": { "slug": "profile", "title": "Your profile" },
      "schema": {
        "tables": {
          "customers": {
            "fields": {
              "name": { "type": "string" },
              "email": { "type": "string" },
              "avatar_url": { "type": "string" }
            }
          }
        }
      },
      "tree": { "block": "NavShell", "props": {}, "children": [] }
    }
    ```

    You can also patch the schema directly with `gv.pages.patch` using a JSON Patch `add` or `replace` targeting `/schema/tables/customers/fields/avatar_url`.
  </NumberedStep>

  <NumberedStep title="Add a Form block with a file field">
    Add a `Form` block whose `fields` array includes a file input. The form runtime renders the file input, captures the upload, and exposes its bytes and metadata as interpolation tokens you reference in `on_submit`.

    ```ts theme={null}
    import { Gavai } from "@gavai/sdk";

    const gv = new Gavai({ apiKey: process.env.GAVAI_API_KEY });

    await gv.pages.patch({
      workspace: "acme",
      id: "page_01H8X4N5P6Q7R8S9T0U1V2W3X4",
      operations: [
        {
          op: "add",
          path: "/tree/children/-",
          value: {
            block: "Form",
            props: {
              schema_table: "customers",
              fields: [
                { name: "name", label: "Full name", type: "string" },
                { name: "email", label: "Email address", type: "string" },
                { name: "avatar", label: "Profile photo", type: "file" },
              ],
              submit_label: "Save profile",
            },
          },
        },
      ],
    });
    ```

    <Note>
      The exact token for the file field type (`"file"`) is not enumerated in the public source. Confirm with the Builder MCP's `get_block_schema` output for the `Form` block before shipping.
    </Note>
  </NumberedStep>

  <NumberedStep title="Wire on_submit to storeFile">
    Set `on_submit` on the `Form` block to call `storeFile`. The form runtime populates `body` from the file input automatically — you do not provide the bytes yourself.

    ```ts theme={null}
    await gv.pages.patch({
      workspace: "acme",
      id: "page_01H8X4N5P6Q7R8S9T0U1V2W3X4",
      operations: [
        {
          op: "add",
          path: "/tree/children/1/props/on_submit",
          value: {
            capability: "storeFile",
            args: {
              filename: "{{form.avatar.name}}",
              content_type: "{{form.avatar.content_type}}",
              body: "{{form.avatar}}",
              public: false,
            },
          },
        },
      ],
    });
    ```

    <Note>
      The interpolation tokens for file metadata (`{{form.avatar.name}}`, `{{form.avatar.content_type}}`) are not confirmed in the public source. The form runtime is expected to auto-populate `body`; verify the exact token shape against the live Builder MCP docs before shipping.
    </Note>
  </NumberedStep>

  <NumberedStep title="Pick signed or public">
    `storeFile` returns either a short-lived signed URL or a permanent public URL depending on the `public` flag.

    | `public`          | URL type                                                                  | Use for                                                                                 |
    | ----------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
    | `false` (default) | Short-lived signed URL, controlled by `expires_in_seconds` (default 3600) | User-private content: uploaded documents, ID photos, anything you do not want crawlable |
    | `true`            | Permanent, publicly accessible URL                                        | Marketing assets, public profile photos, anything embeddable or shareable               |

    ```json theme={null}
    // User-private content, valid for 24 hours
    { "public": false, "expires_in_seconds": 86400 }

    // Public marketing asset
    { "public": true }
    ```

    <Warning>
      File size limits and the allowed-content-type list are not yet enumerated in the public source. Validate size and MIME type on the client before submission so users see clear error messages instead of a generic upload failure.
    </Warning>
  </NumberedStep>

  <NumberedStep title="Persist the returned URL">
    When `storeFile` succeeds it returns a `url`. Chain an `on_success` capability call to `runQuery` to write that URL onto the matching `customers` row.

    ```json theme={null}
    {
      "on_submit": {
        "capability": "storeFile",
        "args": {
          "filename": "{{form.avatar.name}}",
          "content_type": "{{form.avatar.content_type}}",
          "body": "{{form.avatar}}",
          "public": true
        },
        "on_success": {
          "capability": "runQuery",
          "args": {
            "table": "customers",
            "filter": { "email": "{{authUser.email}}" },
            "update": { "avatar_url": "{{storeFile.url}}" }
          }
        }
      }
    }
    ```

    <Note>
      The `on_success` chaining pattern and the `update` argument on `runQuery` are not fully documented in the public source. The shape above matches the intended behavior; verify against the capability catalogue before shipping.
    </Note>
  </NumberedStep>
</NumberedSteps>

## Troubleshooting

<ErrorTable
  errors={[
{
  code: "invalid_input on upload",
  cause: "A required arg is missing, or the file metadata tokens did not resolve.",
  fix: "Log the args the runtime built and confirm filename and content_type are non-empty.",
},
{
  code: "Signed URL expires too soon",
  cause: "expires_in_seconds is shorter than the user needs.",
  fix: "Increase expires_in_seconds, or switch to public: true if the content does not need to be private.",
},
{
  code: "runQuery updates the wrong row",
  cause: "The filter did not match a single record.",
  fix: "Confirm the user is signed in (so authUser.email resolves) and the filter field is unique on the table.",
},
{
  code: "File stored but url is missing",
  cause: "storeFile failed silently — most often an unsupported content type.",
  fix: "Check Workspace → Logs for the failure code and validate the MIME type client-side.",
},
]}
/>

## What you built

You added a URL field to the page schema, added a `Form` block with a file input, wired `on_submit` to `storeFile`, chose between signed and public URL modes, and chained a `runQuery` to persist the URL on the user's record. End-users upload directly from your page; gavAI handles storage, signing, and expiry.

## Next steps

<Columns cols={2}>
  <BrandCard title="storeFile reference" href="/capabilities/store-file" cta="Read reference">
    Full input/output schema for the storeFile capability, including expiry options.
  </BrandCard>

  <BrandCard title="Form block reference" href="/pageblocks/blocks/form" cta="Read reference">
    Props, field types, and event bindings available on the Form block.
  </BrandCard>

  <BrandCard title="runQuery reference" href="/capabilities/run-query" cta="Read reference">
    Execute typed queries against your workspace database from a page block.
  </BrandCard>
</Columns>
