survos/grist-bundle

Grist forms and agent tools for Symfony.

Maintainers

Package info

github.com/survos/grist-bundle

Type:symfony-bundle

pkg:composer/survos/grist-bundle

Transparency log

Fund package maintenance!

kbond

Statistics

Installs: 15

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.26.6 2026-08-28 16:45 UTC

This package is auto-updated.

Last update: 2026-08-28 16:49:05 UTC


README

Grist forms, schema, queries, webhooks — as Symfony services and as agent/MCP tools.

Grist is a spreadsheet whose documents are SQLite files with a REST API. That makes it a good hand-curation surface: a non-developer edits records in a familiar grid, and an app reads them over the API instead of parsing a spreadsheet export.

This bundle exists so that surface can be declared — forms, columns, and webhooks described in code and reconciled — rather than clicked together and then re-clicked on the next document.

Install

composer require survos/grist-bundle

Connections and applications come from survos/record-store-bundle:

# config/packages/survos_record_store.yaml
survos_record_store:
    connections:
        prod:
            driver: grist
            options:
                base_uri: '%env(GRIST_HOST)%/api'
                token: '%env(GRIST_API_KEY)%'
    applications:
        pgsc:
            connection: prod
            id: '%env(GRIST_DOC_ID)%'
            tables:
                Artists:   { id: Artists }
                Locations: { id: Locations }
                Obras:     { id: Obras }

application in every tool below is the key under applicationspgsc here.

Services

Service Does
GristSchemaManager list tables, describe columns, additively add columns
GristQueryRunner read-only SQL, parameterized
GristWebhookManager declare outgoing webhooks, matched by name
GristFormManager create/update/publish Grist forms
GristApplicationLocator resolve an application name to [reference, client]

Agent / MCP tools

Registered only when both symfony/ai-agent and mcp/sdk are installed, so an app that only wants the services doesn't pull in the agent stack.

Tool Purpose
grist_list_applications discover configured applications and their tables — call first
grist_describe_table columns with type, label, and formula
grist_sql read-only SELECT, with ? placeholders
grist_add_columns add missing columns; never drops or retypes
grist_upsert_records write rows matched on a natural key
grist_list_forms / grist_upsert_form form design
grist_attachment_store where attachment bytes live; switch to object storage
grist_list_webhooks / grist_upsert_webhook event wiring

Exposing them

The tools are plain invokable services carrying both #[AsTool] (symfony/ai-agent) and #[McpTool] (mcp/sdk). How they reach a client depends on which transport the app installs:

  • In-process agentsymfony/ai-agent picks up #[AsTool] services via its toolbox; nothing further to do.
  • MCP server — discovery is directory-based: Mcp\Capability\Discovery\Discoverer scans (basePath, directories) for the attribute, so the server must be pointed at this bundle's src/Tool. symfony/mcp-bundle wires that up; consult its config for the current key names rather than copying a snippet from here.

Note that discovery reads docblocks to build each tool's input schema, so the @param lines on __invoke are not decoration — they are what a model sees.

Why grist_sql matters

A document is SQLite, so Ref columns join. One query returns rows already resolved to labels, instead of fetching rows and then looking up every row id:

select o.Code, o.Title, a.Name as Artist, l.Name as Location
  from Obras o
  left join Artists a on a.id = o.Artist
  left join Locations l on l.id = o.Location
 where o.Exhibition = ?

Grist rejects anything but SELECT server-side; GristQueryRunner also rejects it before the round-trip, including verbs hidden behind a leading comment.

Why watchedColIds matters

The usual failure of "enrich a row on update" is a loop: the callback writes a derived column, that write fires the webhook, forever.

$webhooks->upsert(new WebhookBlueprint(
    application: 'pgsc',
    table: 'Obras',
    name: 'pgsc-enrich',
    url: 'https://chijal.org/grist/obra/enrich',
    eventTypes: ['update'],
    watchedColIds: ['YoutubeUrl', 'Photo'], // human-edited columns ONLY
    isReadyColumn: 'Ready',                 // half-filled rows never fire
));

Because the derived columns aren't watched, writing them back can't re-trigger. Grist provides both guards natively — you don't build them.

upsert matches on name, so re-applying a blueprint updates in place. Column ids are validated first: Grist accepts a wrong watchedColIds silently and the hook then simply never fires, which is close to undebuggable later.

API Platform

Grist tables as read/write API resources: serializer groups decide what is public, natural keys appear in the URIs, and one cached full-table read serves the filtering. Registered only when api-platform/core is installed.

#[ApiResource(provider: GristProvider::class, processor: GristProcessor::class)]
#[GristResource(application: 'chijal', table: 'locations', identifier: 'code',
                where: ['Status' => ['activo']], order: ['Name'])]
final class Location { /* #[Groups]-annotated properties */ }
Docs
Overview and install why this replaces a hand-written proxy; a resource end to end
Defining a resource #[GristResource], #[GristColumn], groups, natural-key identifiers
Reading which filters push down to Grist and which do not
Caching why it is mandatory, and the three ways to invalidate
Writing the processor, and why this cannot be the only write path
MCP what would collapse into API Platform's own MCP support

The three things that surprise people: only the resource's declared where/order push down to Grist and everything else is filtered in PHP against a cached full-table read; a natural key containing a dot breaks API Platform's default {._format} item route; and API Platform cannot be the sole write path, because Grist's own grid is a second one.

bin/console grist:api:resources            # which resources are Grist-backed
bin/console grist:api:refresh [Resource]   # drop the cached reads

Console

The same capabilities, for checking what an agent did — and usable in an app that never installs the agent stack:

bin/console grist:describe pgsc                 # tables
bin/console grist:describe pgsc Obras           # columns, types, formulas
bin/console grist:sql pgsc 'select Code, Title from Obras where Exhibition = ?' --arg=aniversario
bin/console grist:webhooks pgsc
bin/console grist:attachments pgsc              # where the bytes live
bin/console grist:attachments pgsc --external   # move them to object storage

Every command takes --json.

Attachments and object storage

With GRIST_DOCS_MINIO_* set on the server, attachment bytes go to S3-compatible storage keyed by content hash:

docs/attachments/{docId}/{sha1}.{ext}

Content-addressed, so identical uploads dedupe and an image server can read the key directly — the app never proxies bytes back out of Grist.

External storage is opt-in per document. Setting the server env vars changes nothing about documents that already exist; the decision is made at creation. grist:attachments <app> --external switches a document over and moves existing attachments across. The document file itself cannot be moved after the fact — if that matters, recreate the document.

Deployment gotchas

  • ALLOWED_WEBHOOK_DOMAINS must be set on the Grist server or every webhook URL is refused with {"error":"Provided url is forbidden"}. Comma-separated base domains; subdomains match. * allows everything and logs an SSRF warning unless GRIST_PROXY_FOR_UNTRUSTED_URLS is set — prefer naming the domains.
  • http:// is rejected except for localhost. Callback endpoints need TLS.
  • X-Requested-With: XMLHttpRequest is required on non-JSON requests; the client sends it.
  • Attachments columns store bytes in the server's object storage when GRIST_DOCS_MINIO_* is configured, keyed by content hash. That storage decision is made per document at creation — setting the env vars later does not migrate existing documents.

Tests

vendor/bin/phpunit -c phpunit.xml.dist

Unit tests cover blueprint validation, the SQL guard, and the tool contract (names match between #[AsTool] and #[McpTool], are prefixed and unique, and descriptions are substantial enough for a model to select on).