Agent guide

Grid AI Agent Authoring Guide

Grid AI Agent Authoring Guide

This document is a strict contract for AI agents (LLMs, code assistants, codegen pipelines) generating Grid model source.

It is more directive than the human-facing style-guide.md because LLMs benefit from unambiguous rules and concrete examples.

Use this as system context. Feed this whole file (or grammar.json) into your agent's system prompt when generating Grid code. The grammar profile is small enough to include in every request.


1. The 12 Hard Rules

These rules are non-negotiable. Violations produce models that don't parse, don't deploy, or behave incorrectly at runtime.

  1. Every line is exactly one statement. No bare expressions. No "implicit" assignments. Every assignment has a target and an operator.
  2. Strings use double quotes only. Single quotes are reserved for sheet/workbook qualifiers.
  3. Range targets must be finite rectangles. A1:A10 is OK. A:A is not.
  4. Inside rule bodies (WHEN/EVERY/AT), use =, ?=, or the compound assignments (+=, -=, *=, /=). Lazy ~= and nested rule blocks are still rejected. No metadata.
  5. Rule blocks run on every runtime. lua_generated, lua_interpreter, and ts all support WHEN/EVERY/AT blocks and <expr> EVERY/AT modifiers. The TS GridService orchestrates rule waves and schedules wakeups for the Lua runtimes, so behavior matches ts exactly. If unspecified, RUNTIME "lua_generated" is the recommended default; switch to ts only when you need spatial references.
  6. Every EVERY and AT must have a missed-run policy (SKIP MISSED or BACKFILL). Pick one explicitly.
  7. Function names are case-insensitive at parse, but emit them uppercase. SUM, not sum.
  8. BLANK is not "". Use BLANK to mean absence; use "" to mean an empty string.
  9. Errors propagate. Any function receiving an error returns that error unchanged unless it's an explicit error-handling function (IFERROR, ISERROR, TRY, etc.).
  10. External functions return asynchronously. Pair every FX_RATE/HTTP_JSON/ML_SCORE with a DEFAULT so downstream cells stay computable.
  11. Don't invent functions. Use only names in functions.md. If you need behavior outside the catalog, compose it from existing functions or stop and ask.
  12. Don't use keywords as identifiers. Grid's grammar is contextual (no truly reserved words), but keyword identifiers create ambiguous parses and break with grammar extensions. The "effectively reserved" list is in reference.md. Pick clearer names like revenue, total_cost, is_active.
  13. Newlines fold inside brackets, separate statements outside. Any expression inside an unclosed (...), [...], or {...} may span multiple lines — that includes function calls, MATCH(...), parenthesized expressions, array and object literals, and comprehensions. Block forms (DO, CASE WHEN, WHEN/EVERY/AT, WITH bindings) also span lines. The two things that still need a single line are chained THEN ... ELSE ladders and the THEN <expr> ELSE <fallback> tail of a WITH. If a chained THEN ... ELSE gets too long, switch to CASE WHEN or extract intermediates with DO. See reference.md §14.1.
  14. External functions cannot appear in rule action bodies. FX_RATE/HTTP_JSON/ML_SCORE must be called at the top level, not inside WHEN/EVERY/AT action bodies. Rely on the function's cache TTL for refresh.

2. Standard Output Template

When asked to produce a Grid model, emit this template:

MODEL "<short title>"
DESCRIPTION "<one sentence>"
RUNTIME "<lua_generated|ts>"
VERSION "1.0.0"
AUTHOR "<author or 'AI Agent'>"
TAGS "<comma-separated tags>"
 
# Inputs
<input cells>
 
# Derivations
<derived cells>
 
# Outputs
<output cells>
 
# Rules (any runtime)
<rule blocks>
 
END MODEL

Fill in every header field. Order the cells from inputs → derivations → outputs. Use # Section comments to group.


3. The Decision Tree

When generating each cell, decide in this order:

Is this an input the user provides?
  YES → A* assignment with type tag, no formula
  NO  → continue
 
Does it depend on an external API or expensive computation?
  YES → use ~= and pair downstream cells with DEFAULT
  NO  → use =
 
Does it have a meaningful semantic type (currency, percentage, etc.)?
  YES → add `as <tag>`
  NO  → no tag
 
Does it branch on conditions?
  All branches compare the same subject? → MATCH
  Different conditions per branch?       → CASE WHEN or chained THEN ELSE
  Single condition?                      → THEN ... ELSE
 
Does it have multiple intermediate values?
  YES → DO ... END
  NO  → inline expression
 
Could it produce an error?
  YES → DEFAULT, IFERROR, or WITH ... ELSE
  NO  → leave bare

4. Always Do / Never Do

Do

  • Emit exactly one assignment per statement.
  • Use spaces around operators: A1 = SUM(B1:B5).
  • Use "double quotes" for strings.
  • Use raw"..." when backslashes should not be processed.
  • Use glob"*.csv" or wild"???-*.csv" for wildcard patterns.
  • Use :identifier symbol literals for state labels.
  • Use json"""...""" / yaml"""...""" / csv"""...""" for embedded structured values.
  • Use A1# to refer to a spilled array as a single value.
  • Use as <tag> for outputs and crossing-boundary values.
  • Use MATCH(subject, val -> result, _ -> default) for value-based matching.
  • Use CASE WHEN for distinct-condition branching.
  • Use WITH ... THEN ... ELSE for error-tolerant external chains.
  • Use ASSERT value > 0 ELSE #N/A instead of nested IF(cond, val, #N/A).
  • Use DEFAULT rather than ?? (both work; DEFAULT is canonical).
  • Use DO ... END rather than top-level LET(...).
  • Use placeholder lambdas (_, _1, _2) only inside higher-order helpers and pipes.
  • Use +/, */, &/ reduction operators when they're clearer than SUM, PRODUCT, CONCAT.
  • Use data[^1] or data[-1] for last-element access instead of INDEX(data, ROWS(data)).
  • Use [2:5] slice notation instead of TAKE(DROP(data, 1), 4).
  • Use UNION/INTERSECT/EXCEPT for set operations.
  • Use QUERY(data, "SELECT ... WHERE ... ORDER BY ... LIMIT ...") for table queries.

Never

  • Emit a bare expression without a target (SUM(A1:B5) on its own).
  • Use 'single quotes' for strings (those are sheet qualifiers).
  • Use full-column / full-row range targets (A:A = 0).
  • Use ~=, ?=, += inside a rule action body.
  • Nest rule blocks (WHEN ... WHEN ... END END).
  • Define a target with both a top-level assignment and a rule action.
  • Emit WHEN/EVERY/AT blocks under RUNTIME "lua_generated".
  • Omit SKIP MISSED / BACKFILL from EVERY/AT blocks.
  • Use placeholders standalone outside higher-order helpers and pipes (A1 = _ * 2 is invalid).
  • Invent function names. If you don't recognize a name, don't emit it.
  • Use reserved words as identifiers (MODEL, WHEN, THEN, END, etc. — see reference.md).
  • Treat BLANK and "" as equivalent.
  • Treat external function failures as cell errors without a fallback — always pair with DEFAULT.
  • Wrap a single function in LAMBDA when an arrow lambda will do.
  • Generate models with no header.

5. Concrete Examples By Task

5.1 "Compute total revenue from monthly figures"

MODEL "Monthly Revenue"
DESCRIPTION "Sum monthly revenue and compute average."
RUNTIME "lua_generated"
VERSION "1.0.0"
AUTHOR "AI Agent"
TAGS "demo"
 
# Inputs
A1 as currency = 12000
A2 as currency = 13500
A3 as currency = 14200
A4 as currency = 15000
A5 as currency = 16800
 
# Derivations
B1 as currency = SUM(A1:A5)
B2 as currency = ROUND(B1 / 5, 2)
 
# Outputs
C1 = `total={B1} avg={B2}`
 
END MODEL

5.2 "Classify customer scores into tiers"

MODEL "Score Tiers"
DESCRIPTION "Bucket scores into excellent/good/fair/poor."
RUNTIME "lua_generated"
VERSION "1.0.0"
AUTHOR "AI Agent"
 
# Inputs
A1 as score = 0.85
 
# Tier classification (CASE WHEN reads well as a table when each arm tests a different condition)
B1 = CASE
  WHEN A1 >= 0.9 THEN :excellent
  WHEN A1 >= 0.7 THEN :good
  WHEN A1 >= 0.5 THEN :fair
  ELSE :poor
END
 
# Output
C1 = `score={A1} tier={B1}`
 
END MODEL

5.3 "Convert with an FX rate"

External functions cannot appear inside rule action bodies. The runtime does not automatically refresh external values on a schedule (cache policy is metadata-only in v1). An external is recomputed when an upstream input changes or when an explicit resolve arrives.

Just call the function at the top level and pair with a DEFAULT:

MODEL "FX Conversion"
DESCRIPTION "Convert a notional amount with a default fallback rate."
RUNTIME "lua_generated"
VERSION "1.0.0"
AUTHOR "AI Agent"
 
# Inputs
A1 as currency = 100000
 
# External signal (computed at deploy; recomputed when A1 changes)
B1 as fx_rate = FX_RATE("EUR", "USD") DEFAULT 1.08
 
# Derivation
C1 as currency = ROUND(A1 * B1, 2)
 
END MODEL

If you need explicit periodic refresh today, drive it from outside the engine (cron job, webhook) by writing to an input cell that B1's formula depends on.

5.4 "Alert on a threshold and time-stamp it"

MODEL "Threshold Alert"
DESCRIPTION "Page ops when load exceeds a threshold."
RUNTIME "lua_generated"
VERSION "1.0.0"
AUTHOR "AI Agent"
 
# Inputs
A1 = 0          # load
A2 = 100        # threshold
 
# Reactive rule
WHEN A1 > A2 THEN
  B1 = "ops-paged"
  B2 = NOW()
END
 
END MODEL

5.5 "Filter and aggregate a list"

MODEL "List Aggregation"
DESCRIPTION "Filter positive values, sort descending, take top 5."
RUNTIME "lua_generated"
VERSION "1.0.0"
AUTHOR "AI Agent"
 
# Input array
A1 = [-3, 7, 1, -2, 9, 4, 8, -1, 5, 6]
 
# Pipeline
B1 = A1
  >> FILTER(_, _ > 0)
  >> SORT(_, -1)
  >> TAKE(5)
 
# Aggregations
C1 = SUM(B1#)
C2 = AVERAGE(B1#)
 
END MODEL

5.6 "Defensive parse of an external JSON response"

MODEL "External Lookup"
DESCRIPTION "Fetch a user record by id; tolerate failure."
RUNTIME "lua_generated"
VERSION "1.0.0"
AUTHOR "AI Agent"
 
# Inputs
A1 = 42
 
# Lazy external call
B1 ~= HTTP_JSON("https://api.example.com/users/" & TEXT(A1, ""))
 
# Defensive chain
C1 = WITH user = B1, name = user.name, email = user.email
THEN `name={name} email={email}` ELSE "unavailable"
 
END MODEL

5.7 "Matrix scenario with broadcasting"

MODEL "Scenario Matrix"
DESCRIPTION "Apply bear/base/bull multipliers to a monthly revenue series."
RUNTIME "lua_generated"
VERSION "1.0.0"
AUTHOR "AI Agent"
 
# Base series
A1 = [120, 135, 142, 150, 168]
 
# Scenario multipliers (column vector)
B1 = [0.92; 1.00; 1.08]
 
# 3x5 scenario matrix (an array comprehension; lowers to MAKEARRAY)
C1 = [INDEX(A1, m) * INDEX(B1, s) FOR s IN 1..3, m IN 1..5]
 
# Per-scenario totals and per-month averages
D1 = BYROW(C1, row => SUM(row))
D2 = BYCOL(C1, col => AVERAGE(col))
 
END MODEL

6. Common Mistakes And Fixes

Mistake Fix
A1 10 A1 = 10
A1 = 'hello' A1 = "hello"
A1 = SUM(B1:B5 A1 = SUM(B1:B5)
A1 = _ * 2 A1 = MAP(B1:B3, _ * 2) or A1 = LAMBDA(x, x * 2)
EVERY duration"PT5M" THEN ... END EVERY duration"PT5M" SKIP MISSED THEN ... END
WHEN ... THEN B1 ~= ... END WHEN ... THEN B1 = ... END (no ~= in rule body)
RIGHT(B1) or LEFT(B1) parsed as a spatial reference The grammar reserves RIGHT(...) and LEFT(...) for the built-in text functions; bare LEFT / RIGHT keywords (no parens) read the adjacent cell. Use LEFT(B1, 3) for the text function or LEFT / RIGHT (no parens) for the spatial reference.
A:A = 0 Use A1 = ZEROS(N, 1) (or FILL(value, rows, cols) / REPEAT(value, count)) — one array cell, not N
A1:A1000 = 0 A1 = ZEROS(1000, 1) — one dependency-graph node, not 1000
MAKEARRAY(rows, cols, LAMBDA(r, c, expr)) for index-dependent fills [expr FOR r IN 1..rows, c IN 1..cols] (comprehension lowers to a single MAKEARRAY cell, with cleaner syntax)
B1 = "default"; WHEN ... THEN B1 = "active" END Pick one ownership; use B1 = cond THEN "active" ELSE "default"
A1 = ADD(5, _) standalone Use A1 = ADD(5, B1) or wrap as A1 = LAMBDA(x, ADD(5, x)) if you genuinely want a function value
IF(ISERROR(EXPR), fallback, EXPR) IFERROR(EXPR, fallback)
IF(ISBLANK(A1), 0, A1) A1 DEFAULT 0
A1 = WITH x = 5 x + 1 A1 = WITH x = 5 THEN x + 1
CASE WHEN x > 0 THEN 1 (missing END) CASE WHEN x > 0 THEN 1 END
A1 as currency = "hello" A1 as currency = 100 (string can't be currency)
Multi-line THEN A ELSE\n B THEN C ELSE D Single-line, or use CASE WHEN (chained THEN ... ELSE is not bracketed)
WITH x = 1, y = 2 THEN x + y\nELSE 0 (ELSE on next line) All of THEN ... ELSE ... on one line

7. The Type System In One Page

Every value has a kind and a type tag.

Kind Examples
blank BLANK
number 42, 3.14, 0xFF, 21pct, 25bps
string "hello", :label
boolean TRUE, FALSE
date d"2026-04-10", dt"2026-04-10T15:30:00Z"
array [1, 2, 3], [1; 2; 3], [1, 2; 3, 4]
object json"""{...}""", regex literals
error #N/A, #DIV/0!, etc.

Type tags add semantic meaning to a kind:

Tag Kind Meaning
currency number money
percentage / pct number percent
bps number basis points
score number 0..1 typical
fx_rate number exchange rate
unit:USD number typed currency code
date / datetime date calendar
duration string ISO duration
cron string cron expression
symbol string enum label

Apply tags at assignment:

A1 as currency = 100000
B1 as percentage = 21pct
C1 as date = d"2026-04-10"

The runtime validates compatibility. If you tag as currency = "hello", the cell becomes #TYPE!.


8. The Error Codes In One Page

Source-writable literals (9):

Code Literal Cause Recover
N/A #N/A Lookup miss; explicit "no value" IFNA, DEFAULT
DIV/0 #DIV/0! Divide by zero IFERROR, guard
VALUE #VALUE! Wrong arg shape/type IFERROR, WITH
REF #REF! Invalid reference structural fix
NAME #NAME? Unknown function/argument check spelling
NULL #NULL! Empty range intersection range syntax
NUM #NUM! Numeric domain error input validation
CALC #CALC! Iterative method failure tune tolerances
SPILL #SPILL! Spill blocked move formula

Runtime-only (do not write as literals — they're generated):

Code Cause
TYPE Type-tag mismatch on assignment
CIRC Circular reference detected at build

Both can be detected with ISERROR(value) or value IS ERROR.


9. Operator Precedence Cheat Sheet

For the full table see reference.md. The five most common decisions:

  1. Power (^) is right-associative. 2^3^2 = 2^(3^2) = 512.
  2. & binds tighter than comparison. "a" & "b" = "ab" parses as expected.
  3. Comparison is non-associative. 0 < x < 10 is not valid. Use x BETWEEN 0 AND 10 or 0 < x AND x < 10.
  4. THEN ... ELSE is lower than logical operators. A AND B THEN x ELSE y reads as (A AND B) THEN x ELSE y.
  5. Pipes are below all conditional and logical operators. cond THEN x ELSE y >> f reads as (cond THEN x ELSE y) >> f.

When in doubt, parenthesize.


10. Self-Validation Checklist

Before returning a generated model, walk this checklist:

  • Header has MODEL, DESCRIPTION, RUNTIME, VERSION, AUTHOR, and at least one TAGS entry.
  • RUNTIME matches the features used (ts if any rule blocks).
  • Every assignment has a target on the left, an operator, and a non-empty expression.
  • All strings use double quotes.
  • No range target larger than 100 cells (refactor with array formulas if larger).
  • Every external call (FX_RATE, HTTP_JSON, ML_SCORE) is paired with a DEFAULT downstream.
  • Every EVERY and AT block has SKIP MISSED or BACKFILL.
  • No rule body contains ~=, ?=, or +=.
  • No top-level assignment shares a target with a rule action.
  • No function name appears that isn't in functions.md or the external registry.
  • Identifiers don't collide with reserved words.
  • All published outputs carry a type tag.
  • No multi-line MATCH(...), multi-line function call, or multi-line chained THEN ... ELSE.

If any item fails, fix it before returning.


11. Machine-Readable Schema

For programmatic use, grammar.json provides:

  • All function signatures with parameter names, types, return types, variadic info, and external contracts.
  • All operators with precedence and associativity.
  • All literal forms.
  • All directives, runtimes, evaluation modes, and execution classes.
  • All error codes with literals and descriptions.
  • All rule block templates.
  • Type tag taxonomies.

A typical agent loop:

  1. Fetch grammar.json.
  2. Build a function-name lookup table from functions[*].name.
  3. When generating a function call, look up the signature to validate argument count and named-arg names.
  4. After generating each cell, run the self-validation checklist above.
  5. Optionally compile the generated model with npm run compile:model and surface any diagnostics back to the user.

12. When To Stop And Ask

The agent should stop and ask the user when:

  • The user requests behavior that requires a function not in the registry (don't invent one).
  • The user requests CRDT-style multi-writer collaboration (out of v1 scope).
  • The user requests charts, pivots, or other visualization features (out of v1 scope).
  • The user requests a custom external function (not user-definable in v1).
  • The user's intent is ambiguous about runtime profile and the model uses borderline features.

Better to ask one question than to emit a model that won't deploy.


13. See Also