Running: CLI, Python, JavaScript, agents
CLI
The command is grida fx, from npm install -g grida. Without Node, the grida Python package
runs the same engine: python -m grida.fx <verb> takes the same arguments (pip install grida).
Usage, errors and hints name the command you ran; what FX records is the same either way. The
standalone grida-fx command and the source setup are in
CONTRIBUTING.md.
| Command | |
|---|---|
grida fx init [directory] [--template NAME] [--json] | start a new project in an empty folder from the starter or a template (--list-templates lists them), or check an existing fx.yaml; never overwrites |
grida fx start [--project directory] [--port N] [--background] [--open] [--json] | serve the project's dashboard and recorded runs; foreground by default |
grida fx status [--project directory] [--json] / stop [--project directory] [--json] | inspect or stop that project's service |
grida fx logs [--project directory] [--lines N] | read a bounded tail of service logs |
grida fx inspect RUN --control [--json] | observe an exact invocation's local control availability |
grida fx cancel RUN [--invocation ID] [--wait] [--timeout DURATION] [--json] | request cancellation; optionally verify local completion (Run control) |
grida fx plan <target> [inputs] [--routes file]… [--max-usd N] [--check] [--expect-cached] [--json] [--open] [--standalone] | expand, check, price; optionally open the plan canvas. Never spends. |
grida fx run <target> [inputs] [--routes file]… [--live] [--max-usd N] [--yes-up-to N] [--deliver out=path]… [--name NAME | --resume NAME | --run folder] [--stand-in file.py#function | file.ts#function] [--open | --no-view] [--standalone] | run; --live admits paid calls, needs a ceiling (unless every call is on your own Codex or Grok Build) and reads the provider keys; --name creates a named run, --resume continues it, --run selects a folder; --stand-in answers paid calls with your function instead, offline (below) |
grida fx reroll <run> <step-path> / grida fx pick <run> <step-path> <take> | takes (Cost) |
grida fx takes list <target> / grida fx takes mv <target> <old> <new> | inspect or repair a takes file |
grida fx jobs [--forget <key>] | the cache's long provider jobs, settled ones included (Resuming); --forget clears one once you have checked the provider |
grida fx inspect <folder or workflow id or workflow id/name> [--verify] [--json] [--open] [--standalone] | summary; a workflow ID selects its newest-created run; --verify re-checks every placed file; --open opens its canvas |
grida fx nodes [type] | built-in and project node types, with settings and routes |
grida fx schema <target> | the JSON Schema its inputs: compile to |
grida fx doctor [target] | the Python, Node, keys, routes and tools a workflow needs, the target's own routes first (below) |
grida fx lock [where] [--same <node>]… [--check] | node and adapter version locks; --same confirms a node type's or an adapter's <path>#<attr>, repeated for several |
grida fx expand, identity, price <target> [inputs] | the expanded graph, each instance's identity, the price by phase, as JSON |
grida fx project <run> | a run's record projected to its state, as JSON |
grida fx observe <run> [--after cursor] [--limit 256] / observe <run> --snapshot | consistent snapshot or bounded recorded events, as JSON (contract) |
grida fx --help lists what your installation has; the service commands need FX 0.2 or later.
Use init, then start --background for normal project work. The service uses
loopback port 8787 by default and keeps run pages available after execution exits.
run prints an available run URL after initialization and before run-phase steps;
--open requests browser launch. Commands never start a background service implicitly.
Execution still works when the service is stopped.
Name a run or continue it
grida fx run character-rig
grida fx run character-rig --name character_1_rig_ready
grida fx run character-rig --resume character_1_rig_ready
grida fx inspect character-rig --open
grida fx inspect character-rig/character_1_rig_ready --open
Without a name, each command creates a fresh run record; matching cached results
can still make it finish immediately. --name creates once and refuses an existing
name. --resume requires that name and the same current plan and execution mode:
supply the same inputs, routes, takes and builder arguments again. It appends an
invocation, retains recorded work and spending, and never replaces history.
--run PATH remains the explicit create-or-resume folder interface, including
for SDK users. The three options are mutually exclusive; there is no overwrite.
Names are case-sensitive, start with a letter or digit, and contain at most 64 letters, digits, underscores or hyphens. They are scoped to one project and recorded workflow ID/source. Inspection refuses an ID shared by several sources; use an actual run folder to select one precisely. A changed workflow definition or inputs needs a fresh run, and stays under the same workflow in the index.
Put workflow inputs that overlap CLI options after --, or use --inputs FILE:
grida fx run greeting --name baseline -- --name Ada. Plan commands still accept
grida fx plan greeting --name Ada as an authored input. Named runs, like the project service,
need FX 0.2 or later.
For independent tests and probes, run --standalone hosts its own viewer on an
available port until execution ends. Projectless runs use this model by default.
plan --standalone and inspect --standalone host their selected data until
interrupted. --standalone does not disable cache reuse. --no-view skips run
viewer integration and conflicts with --open; SDK runs use it. JSON plan/inspect
output cannot be combined with browser-serving flags. See
Viewing for the lifecycle and independent observation commands.
Exit status. 0 when the command did what it was asked: for run, the run is ok and every
--deliver found its output. 1 when it read everything and refused or stopped: a plan with
problems, a run refused before it started (a live run without a ceiling, a folder that holds
another plan), a run that is not ok, an output that --deliver did not find. 2 for unreadable
input or a command-line mistake, with grida fx: <message> on stderr. 130 for
process-owned interruption, including planning, or accepted run cancellation
(Ctrl-C, SIGTERM or cancel): owned node hosts are cleaned up and completed work
is kept (Resuming). If finalization already won,
the execution can preserve its actual terminal result and normal exit status.
Control commands have their own outcomes and exit codes. First Ctrl-C/SIGTERM requests graceful run cancellation, with no automatic runner force timer. A second explicit Ctrl-C is emergency force; repeated SIGTERM is idempotent. Cancelling a separate waiter never stops waiting by forcing the run.
Route tables. Routes and their prices come from FX's built-in table, then the tables fx.yaml
lists under route_tables, then each --routes file in the order given. A later table's entry
for the same capability and route replaces an earlier one, and giving any --routes file leaves
the built-in table out (identity.md §7).
An entry may extends: another route of the same capability and provider, from an entry read
before it (earlier in the same table, or in a table read before) or from the built-in table (even
when a --routes file leaves that table out), and state only what differs. It takes the base's
price, features, input-picture limit, pacing and contract; a member it states replaces the
base's whole, except contract, whose members merge one by one. extends is not part of the
route's identity, so an entry that extends a route to reprice it keeps every cached result.
Where FX's adapters serve the capability and provider, a contract that leaves out adapter
gets the name of the provider's default for the capability, and one that leaves out
adapter_behavior gets the behaviour of the adapter it names (none when FX has no adapter of
that name). A project route on such a capability and provider whose contract left out
adapter, or named one of FX's adapters and left out adapter_behavior, under FX 0.6.1 or
earlier hashes differently once after the upgrade, so its steps and paid calls run again; a
route on any other pair keeps its fingerprint. A route on a provider the project declares is
completed the same way, from its family's adapters or its own adapter
(Community providers).
The built-in table ships with the command. Its prices are planning allowances in US dollars, not provider quotes: what a call costs is settled from what the provider reports, else from the whole amount held for it (providers.md §3).
| Capability | Built-in routes | Key |
|---|---|---|
image.generate | openai/gpt-image-2.5-sunburst@gg (a call, by size: 0.70); also gpt-image-2.5-sunburst@openai (0.70), openai/gpt-image-2.5-sunburst@openrouter (0.70), openai/gpt-image-2.5/sunburst@fal (0.70); imagegen@codex (0: free, on your own Codex); imagine@grok (0: free, on your own Grok Build) | GG_API_KEY, else each provider's; none for Codex and Grok Build |
image.edit | openai/gpt-image-2.5-sunburst@gg (a call, by size: 0.85); also gpt-image-2.5-sunburst@openai (0.85, with masks), openai/gpt-image-2.5-sunburst@openrouter (0.85), openai/gpt-image-2.5/sunburst@fal (0.85, with masks); imagegen@codex (0: free, on your own Codex); imagine@grok (0: free, on your own Grok Build) | GG_API_KEY, else each provider's; none for Codex and Grok Build |
structured.generate | openai/gpt-5.6-sol@gg (0.60), openai/gpt-6-astra@gg (1.50); also openai/gpt-5.6-sol@openrouter (0.60), openai/gpt-6-astra@openrouter (1.50) | GG_API_KEY, else OPENROUTER_API_KEY |
agent.turn | openai/gpt-5.6-sol@gg (0.10), openai/gpt-6-astra@gg (1.50); also openai/gpt-5.6-sol@openrouter (0.10), openai/gpt-6-astra@openrouter (1.50) | GG_API_KEY, else OPENROUTER_API_KEY |
video.generate | alibaba/wan-3.0@gg (per second, by resolution, at most 30 s: 0.20; from the prompt or a first frame, no last frame); also google/gemini-omni-flash/v1.1/image-to-video@fal (at most 10 s: 0.375) | GG_API_KEY, else FAL_KEY |
mesh.generate, mesh.rig | tripo/p2@gg (2.50), tripo/rig-v1.0@gg (0.50); also P2-20260801@tripo (2.50), v1.0-20240301@tripo (0.50) | GG_API_KEY, else TRIPO_API_KEY |
music.generate | google/lyria-3-pro-preview@openrouter (0.50) | OPENROUTER_API_KEY |
sound.generate, speech.generate | eleven_text_to_sound_v2@elevenlabs (0.10), eleven_v3@elevenlabs (0.05) | ELEVENLABS_API_KEY |
An image call is priced by its size. A size the route's tiers list is priced at its tier:
1024x1024 is 0.29 for a generate on openai/gpt-image-2.5-sunburst@gg, and
0.43 for an edit. auto, no size, or a size no tier lists is priced at the whole
range above (providers.md §3).
A project picks one with routes: in fx.yaml (or a step's route:). To change a price or add
a route, list a table under route_tables:: its entry for the same capability and route replaces
the built-in one, and extends keeps everything but what it states:
# routes/prices.yaml, listed in fx.yaml as route_tables: [routes/prices.yaml]
fx: routes/v1
routes:
- capability: image.generate
route: openai/gpt-image-2.5-sunburst@gg
extends: openai/gpt-image-2.5-sunburst@gg
price: { low_usd: 0.09, high_usd: 0.80 }
This entry holds $0.80 for each call, more than the built-in entry holds for any size. A price
it states replaces the base's whole, size tiers included, so a price meant for one size holds too
little for a larger one.
A route on a provider FX has no adapter for, or whose contract names an adapter FX does
not have, still plans and prices, but a live call on it is refused (no adapter serves …). A
model FX does not list, on a provider it has an adapter for, runs on a route of your own
(AI providers), and a provider FX does not ship runs
once the project declares it, with its family or an adapter of its own
(Community providers). grida fx nodes <type> lists the routes
that serve a type; grida fx doctor says which of them a live run could serve.
<target> is one of:
- a workflow file (
workflows/gallery.yaml); - a workflow id (
concept-gallery), looked for among the.yamlfiles at the project root, then in the foldersfx.yamllists underworkflows:,workflows/by default (Getting started); - a builder, a function that returns the workflow:
level_art.py:buildin Python, orlevel_art.ts:buildin TypeScript (.mts,.jsand.mjsfiles too). Its arguments are passed with--arg name=value.
Keys and live runs
Which provider serves what, and where to get each key: AI providers.
One key: Grida Gateway. GG_API_KEY is the one key for every route on Grida
Gateway (@gg): text, agent turns, images, video, 3D models and rigs, billed to your
organization's Grida AI credits. Create it at console.grida.co, where
you also add credits and see what each call was charged. FX books a GG call at what GG charged
for it, read back once GG has settled it; a charge that has not settled within a short wait is
booked at the call's whole hold (GG's adapter reference).
Your own provider keys. Every other route takes its provider's key under its usual name:
OPENAI_API_KEY, OPENROUTER_API_KEY, FAL_KEY, TRIPO_API_KEY and ELEVENLABS_API_KEY. Sound,
speech and music have no GG route yet. GG_BASE_URL, OPENAI_BASE_URL,
OPENROUTER_BASE_URL, FAL_BASE_URL and ELEVENLABS_BASE_URL point a provider at another
address, such as a proxy. A key that is missing refuses only the calls that need it, before
anything is sent and for nothing: the step fails, refused with GG_API_KEY is not set.
Without --live no key is used. A provider FX does not ship is declared in fx.yaml, with the
variables of its own key and address
(Community providers).
No API key: your Codex. With Codex installed and signed in to your ChatGPT account, the route
imagegen@codex draws images with no key and nothing to buy: it is priced at $0, and a run whose
calls are all on it needs no --max-usd. It has caveats, on sizes above all:
Images from your own Codex. With Grok Build signed in to a Grok subscription,
imagine@grok does the same: Images from your own Grok Build.
Proxies. A live run sends https requests through the proxy your environment names
(HTTPS_PROXY or ALL_PROXY, minus the hosts in NO_PROXY), tunnelled, so the proxy never reads
a key. Requests to localhost or a loopback address, the only hosts a plain http base URL may
name, are never proxied: they go straight there, and HTTP_PROXY is not used
(adapters.md §2).
The .env file. A variable set (and not blank) in the environment wins. Otherwise FX reads it
from the .env file of the project you run from, if there is one, and only the eleven key and
base-URL names above and the ones that project's fx.yaml declares: a line naming anything else
is skipped without being read. A line is NAME=value (an export before it is fine), with the
value unquoted, in 'single quotes' taken as written, or in "double quotes" with JSON escapes;
blank lines and # comments are skipped. A file FX cannot use stops a live run before anything is
planned (exit 2), with a message that names the variable and the line and never the value: a
symlink or anything but a plain file, text that is not UTF-8, a name given twice, an empty value,
a quote out of place, or a control character in the value. Keys are never printed, logged or
recorded. GRIDA_FX_DISABLE_DOTENV=1 turns the file off, so only the environment counts
(credential-free checks set it).
Trying --live without the network. With GRIDA_FX_NETWORK=off, a live run builds its
adapters as usual, but nothing FX sends leaves the machine: every request through FX's transport
(FX's adapters, and a project adapter's fetch) is
refused before it is sent, and the step fails with a message that names GRIDA_FX_NETWORK, for
nothing.
A project adapter's own HTTP client is not stopped: such an adapter checks the switch itself.
Tests of live paths use it.
Checking keys and routes. grida fx doctor prints, after the engine, Python and Node lines,
where each key comes from (and for each provider fx.yaml declares, a provider line with its
address too, and for one served by its own adapter an
adapter line), which Codex and Grok Build it found, and which routes a live run could serve, for
the project you run from (the built-in table and its route_tables); with a target, the tools
and node types the workflow needs follow:
engine grida-fx 0.7.0
python .venv/bin/python (grida 0.7.0)
node /usr/local/bin/node (@grida/fx 0.7.0)
key GG_API_KEY present (.env)
key OPENAI_API_KEY missing
key OPENROUTER_API_KEY missing
key FAL_KEY present (environment)
key TRIPO_API_KEY missing
key ELEVENLABS_API_KEY missing
program codex /usr/local/bin/codex (PATH, 0.160.1)
program grok not installed
route image.generate gpt-image-2.5-sunburst@openai no key (OPENAI_API_KEY)
route image.generate imagegen@codex servable
route image.generate img-a@acme no adapter
route image.generate openai/gpt-image-2.5-sunburst@gg servable
route mesh.rig tripo/rig-v1.0@gg servable
route mesh.rig v1.0-20240301@tripo no key (TRIPO_API_KEY)
…
A program line names the Codex or Grok Build a live run would start, where FX found it
(GRIDA_FX_TOOL_CODEX, PATH or the ChatGPT app; GRIDA_FX_TOOL_GROK, PATH or
~/.grok/bin) and, for Codex, the version it names (no version when it names none).
A route is servable, has no key (and names the variable to set), is not installed (codex)
or not installed (grok) (no Codex or Grok Build found), too old (codex 0.150.0; FX needs 0.160.1+) or no version (codex) (a Codex a live run would refuse), or has no adapter (no
adapter of FX serves the capability on that provider, or none under the adapter its contract
names). A missing key alone doesn't change doctor's exit status, unless the target uses a route
that needs it (below); a .env or base URL that a live run would refuse does: doctor prints
keys <why> and exits 1. So does an adapter that cannot load, as a plan would refuse it:
its line reads adapter acme adapters/acme.py#acme cannot load: <why> where a loaded one reads
adapter acme adapters/acme.py#acme version 1: structured.generate (request).
The target's routes come first. With a target, grida fx doctor <target> lists, right after the
Python and Node lines, the routes the workflow's paid steps use, each with whether a live run could
serve it and how many steps use it (a repeated step counts once; groups and used workflows are
included):
engine grida-fx 0.7.0
python .venv/bin/python (grida 0.7.0)
node /usr/local/bin/node (@grida/fx 0.7.0)
uses image.generate imagegen@codex servable (8 steps)
key GG_API_KEY missing
…
Routes are read from the workflow and fx.yaml, so doctor needs no inputs. A step with no route
shows no route, and a route the catalog doesn't have shows not in the catalog. Doctor exits 1
when any route the target uses can't be served, since that stops a live run; the full list of
keys and routes still follows. These lines need no Python or Node, so they print even before
uv sync or npm install.
Doctor takes no --routes: routes a run adds with --routes show as not in the catalog.
The Python line. FX's Python node host needs Python 3.11 or later with grida. The Python
line names the interpreter FX would use and says what it found:
python .venv/bin/python (grida 0.7.0): ready;python python3 too old (3.9.6; FX needs 3.11+): runuv syncorpip install gridain the project, or setGRIDA_FX_PYTHONto a newer Python;python python3 no grida: installgridainto that Python;python python3 NOT FOUND: there is no such program;python python3 cannot start: <why>: it started and failed, for example agridatoo old for this FX.
The Node line. FX's TypeScript node host needs Node 24 or later and @grida/fx
(Getting started). The Node line names the Node FX
would use and says what it found:
node /usr/local/bin/node (@grida/fx 0.7.0): ready;node /usr/local/bin/node too old (22.12.0; FX needs 24+), orbun not Node.js 24+: install Node 24 or later, or setGRIDA_FX_NODEto one;node /usr/local/bin/node no @grida/fx: runnpm install @grida/fxin the project;node node NOT FOUND: there is no such program;node /usr/local/bin/node cannot start: <why>: it started and failed, for example an@grida/fxtoo old for this FX.
A project with no TypeScript nodes needs none of it: without a package.json, the Node line gives
only what that Node answered (node /usr/local/bin/node (Node 24.14.0)). The lines
themselves don't change the exit status. With a target whose nodes need a host that can't start,
doctor then stops with the message a plan would give (exit 2).
Inputs
Ways to pass them:
- Flags: the kebab-case form of each input (
--poster inputs/poster.png,--max-entities 24). - Files:
--inputs file.yaml, repeatable and merged in order. - Both together, with flags winning.
Paths and lists:
- Paths inside an inputs file are relative to that file.
- List and map inputs need a file. A
filesinput also takes a glob:--sprites 'art/*.png'.
# inputs/harbor.yaml, from the looping-parallax example
canvas: { width: 640, height: 360 }
layers:
- { id: far_cliffs, file: ../art/far_cliffs.png, order: 0, parallax: 0.2 }
- { id: near_masts, file: ../art/near_masts.png, order: 1, parallax: 0.7, repeat: repaint }
Both layers leave out offset_y, and far_cliffs leaves out repeat: those fields have
defaults (0 and mirror). order has none, so every layer gives it.
Runs
Each run is a folder:
runs/concept-gallery/2026-10-02-1/
events.jsonl # everything that happened, in order (the record)
plan.json # the plan the run started from: workflow, inputs, every step instance
outputs/ # the declared outputs, by name and key
files/ # every step's results: one folder per step path and take
- Where:
runs/<workflow id>/<date>-<n>/in the project you run from, a new folder each time;--run <folder>names one instead, relative to where you are. files/has one folder per take:files/<step>/holds take 1, and any other take has a folder of its own, its take numbers after#(files/draw#2/,files/entity__ada__.draw#1.3/for a take inside a regenerating group). Every take a run made is there, andinspect --verifychecks each one.events.jsonlis the source of truth (anfx-run-events-v1log);inspectandprojectare built from it.plan.jsonis the samefx-graph-v1documentgrida fx expandprints, and names the takes filererollandpickwrite.- Removing is safe: results live in the cache.
grida fx runs removeremoves runs you no longer need (Cleaning up runs); it never takes a running run. - Copying is safe: a run folder can be inspected on another machine.
A failed step does not stop the run: its body failed, a paid call was refused (the ceiling, no
--live), or an assertion did not hold. The steps that need it are skipped, everything else
runs, and the run ends failed, naming each failure. A run that stops before it is done (a phase
past --yes-up-to, an interruption) is incomplete. Everything a run finished is kept and
shown, and running the same command into the same folder (--run) continues from there; a
folder that holds a run of another workflow or other inputs is refused.
run prints the plan, then its selected folder and invocation before executing.
Its result summary follows execution, each label in a column of ten:
run runs/one
result failed spent $0.00
failed nope#1: refused on purpose
failed after#1: something it reads failed
failed bang#1: RuntimeError: kaboom y
result is ok, incomplete or failed; a failed line follows for each step that failed or
was skipped, in the plan's order; stopped says why an incomplete run stopped. A run refused
before it starts prints refused: <why> instead (exit 1). A body that raises fails with
<exception type>: <message>; a step past its timeout: with ran past <n> seconds, and it is
not run again, even under retry="engine".
After a run. These commands never run a step or spend anything:
-
grida fx inspect <run>: the run's state and a count of its steps' states, what it spent, and each failure. Given a workflow id instead of a folder, it reads that workflow's newest run.--verifyrehashes every file its steps placed and exits 1 if one differs from its record;--jsonprints the same summary as JSON, each step with its files. A started step whose paid call is queued for its route's slot (a route withconcurrency: 1runs one call at a time) is counted aswaiting, notrunning, while it waits:67 steps: 57 pending, 1 running, 3 succeeded, 6 waiting; in--jsonsuch a step haswaiting_for, the route. -
grida fx project <run>: the record projected to its state, as JSON: each instance'sstate(running,waiting,succeeded,failedorskipped), withwaiting_forwhile it waits for its route,cacheandfactswhen it succeeded orerrorwhen it did not, and the latestrun_started,run_finishedandrun_cancelledevents. -
grida fx reroll <run> <step-path>: writes the take after the latest one this run drew of that step into the takes file the run'splan.jsonnames, and prints the command that repeats the run:icon.takes.yaml: draw uses take 2 from now onnext grida fx run workflows/icon.yaml --inputs inputs/lantern.yaml --max-usd 1 --live -
grida fx pick <run> <step-path> <take>: writes that take into the takes file, with the digest of the first port holding one file in the outputs that take finished with in this run (a take this run never finished is written without one), and prints the samenextline. A later run fails the step if the take no longer produces that file (Cost). -
The
nextline is the run's own command, from what its record holds: the target, its--inputsand--routesfiles (relative to where you are),--max-usd, and--liveonly when that run's latest invocation was live: a run resumed without--liverepeats without it. It leaves out--run,--nameand--resume(a run of another take is a new run), and--deliverand--yes-up-to. A run given workflow input flags or--arg, a file outside the project, a builder run from anywhere but the project's root, or a run started from an SDK cannot be repeated from its record; then the line saysnext run icon again with the same inputs and options. So does a run whose project's run index does not record its folder (a copied folder), and one whose target is a workflow id, a JSON workflow file or a builder, read from another project, where that target would plan another workflow. -
The project:
rerollandpickwrite the takes file of the project that ran the run, the one whose run index records its folder, wherever you run them from; a folder no project records (copied from elsewhere) uses the project you are in. -
grida fx takes list <target>: one line per entry of a workflow's takes file,<step-path> take <n>[ <digest>];takes mv <target> <old> <new>moves an entry to a step's new path and refuses to overwrite another entry. Neither takes a builder target. -
grida fx jobs: the cache's long provider jobs, one line each:<key> <state> <capability> on <route>, take <n>. Asubmittingjob FX could not confirm ends with why, naming the provider's job id when it returned one.--forget <key>removes one, so the next run submits its call anew.
Delivering outputs
From the game-build example's kitewharf/assets/:
grida fx run level_art.py:build --arg level=../levels/docks.toml --live \
--deliver plate=../game/art/docks/ground.png \
--deliver icons=../game/art/docks/icons/{key}.png
- Names are checked before running: an output the workflow does not declare is refused (exit 2) before anything is planned or run.
- Delivery copies the declared outputs that exist, after the run, to paths relative to where
you are.
{key}stands for each element of a list or keyed output (its key, else its position), made a safe path; an output of one file takes a path without it. It is idempotent: unchanged files aren't rewritten. - It lists anything missing (a skipped instance, an incomplete run), and the command then exits non-zero.
- Delivering a partial result is safe: every delivered file was verified against its record.
From Python
The Python SDK (pip install grida) drives the same engine as the command, and needs no Node.
from grida.fx import run
result = run(
"workflows/gallery.yaml", # a workflow file, an id, a builder "file.py:function", or a plan
inputs={"synopsis": "inputs/synopsis.md", "poster": "inputs/poster.png"},
live=True,
max_usd=10,
)
print(result.ok, result.cost, result.incomplete)
for key, image in result.outputs["images"].items(): # keyed collection
verdict = result.steps[f"entity['{key}'].review"].facts["verdict"]
print(key, image.path, verdict)
result.deliver({"images": "out/{key}.png"})
- Targets are what the command takes: a workflow file, an id, or a builder
(
"file.py:function", or a TypeScript one) with its arguments asarguments={"level": "levels/docks.toml"}. AWorkflowobject a builder returns is not accepted yet (TypeError): name the builder instead. - Planning first:
grida.fx.plan(...)returns the plan, with.estimate(),.phases()and.problems.grida.fx.run(plan, live=True)runs it with the target, inputs and ceiling it was planned with (live,yes_up_to, and one ofname,resumeorrun_dirmay still be given). - Async:
await grida.fx.run_async(...). - Saved records and observation:
load_run/load_run_asyncreturn aRunRecordwith a pinned folder and engine inspection summary. Use its snapshot/event reads and asyncfollowiterator to observe independent execution. The SDK run guide shows Python and JavaScript journeys. - A refused plan raises
PlanRefused, and a run refused before it starts raisesFxError. A failed step doesn't: checkresult.ok, thenresult.failures. It holds, by instance id (draw#1), each step that failed or was skipped: itspath,message,code(the error's name, such asnode_failureorcapability_refused;Nonefor a timeout, an assertion that did not hold, or a skip),factsandskipped.result.stoppedsays why a run stopped before it was done, and isNoneotherwise. - Stand-ins:
stand_in=answeranswers the run's paid calls with your function, offline and for nothing (below);result.stand_insays whether a run had one. - Your Python runs the Python nodes when nothing else is chosen: the SDK (and
python -m grida.fx) tells the engine the Python it runs in, and Python node bodies, built-ins with Python bodies and a.py--stand-infile run in it when neitherGRIDA_FX_PYTHONnor a project.venvchooses one. SetGRIDA_FX_PYTHONto choose another.
From JavaScript
The JavaScript SDK (npm install @grida/fx, Node 18 or later) drives the same engine from
JavaScript or TypeScript, with JavaScript names; its README lists the API.
import { run } from "@grida/fx";
const result = await run("workflows/gallery.yaml", {
inputs: { synopsis: "inputs/synopsis.md", poster: "inputs/poster.png" },
live: true,
maxUsd: 10,
});
console.log(result.ok, result.chargedUsd, result.incomplete, result.failed);
const images = result.outputs["images"]; // a keyed collection is a Map, in its order
- Targets are what the command takes: a workflow file, an id, a builder
(
"level_art.ts:build", or a Python one) with its arguments asargs: { level: "levels/docks.toml" }, or a plan fromplan(…). - A refused plan rejects with
PlanRefused, and a run refused before it starts withFxError. A failed step doesn't: checkresult.ok, thenresult.failed(instance ids) andresult.steps. - Stand-ins:
standIn: answeranswers the run's paid calls with your function, in your own process, offline and for nothing (below);result.standInsays whether a run had one. - Your Node runs the TypeScript nodes when nothing else is chosen: the SDK (and
grida fx) tells the engine the Node it runs on, and TypeScript node bodies, builders, adapters and a TypeScript--stand-infile run on it unlessGRIDA_FX_NODEchooses another. Hosting them needs Node 24 or later (Getting started); run under Bun, the SDK leaves the choice tonodeonPATH.
Stand-ins (testing without a provider)
A stand-in run answers each paid call with a function you write instead of a provider: offline, for nothing, with the answers your test chooses, as a mock model does in an AI SDK. The rest is a real run: every step runs, your nodes and judges see the answers, and the run is recorded. A call answered before is replayed from the stand-in's own cache (below); the function is asked only the rest.
Ways to give one:
-
From Python,
grida.fx.run(..., stand_in=answer)(orrun_async) callsanswerin your own process, so closures, counters and lists of scripted answers work, and each run may get another function. -
From JavaScript,
run(…, { standIn: answer })does the same in your own process (below). Either SDK hands the engine a socket on its standard input (--stand-in -, for SDKs rather than for typing), so both need macOS or Linux. -
From the command,
--stand-in <file>#<function>, with a.pyfile, or a.ts,.mts,.jsor.mjsone, relative to where you are; it may lie outside the project. A file that is missing or does not load, or a function it lacks, is refused before the run starts (exit 2).- FX loads a Python file in a Python of its own:
GRIDA_FX_PYTHONwhen it is set, else the.venvof the project you run from, else the SDK's Python, elsepython3onPATH. Unlike your nodes, it never uses the.venvof a workflow's own project when that is another one (Getting started). The file's folder comes first onsys.path, aspython <file>would have it. - FX loads a TypeScript file in a Node of its own, the one your nodes would run on
(Getting started), with the
@grida/fxof the project you run from, else the one that started FX. The file's imports resolve from its own folder, asnode <file>would have them, and the function is its export of that name.
grida fx run icon --stand-in tests/stand_in.py#answer -- --name "copper lantern"run runs/icon/2026-10-06-1stand-in tests/stand_in.py#answerresult ok spent $0.00 - FX loads a Python file in a Python of its own:
Never with --live or --yes-up-to (exit 2): a stand-in run spends nothing and runs every
phase. --max-usd is accepted and changes nothing, since nothing is reserved: the plan shows the
ceiling but never warns that the run stops before crossing it, and notes no capped route.
--deliver works as in any run. plan and the other planning commands take no stand-in: planning never makes
a paid call.
The function takes one call, and may be async:
call. | |
|---|---|
capability, route.id, key | what is asked, of which route, and the call key |
take, takes | the take being drawn (2 for a second take), and the whole take list |
instance.id, .path, .step | the instance making the call: its id (entity['ada'].draw#2), its step path with repeat keys (entity['ada'].draw) and its declared step (entity.draw) |
request | the request, with each file in it, however deep (references, an agent's messages[*].images), as an InputFile: .path, .read_bytes(), .facts |
files | every file of the request, as an InputFile, by digest |
What it returns or raises decides the call:
answer(call) | The call |
|---|---|
returns an Answer | is answered: checked (below), stored and recorded, at cost_usd: 0 |
returns DECLINE | goes on as in a run without a stand-in, and fails not_live: … is a paid call the stand-in declined. Made again, it is asked again |
raises CallRefused("…") | is refused (capability_refused), as when a provider refuses before sending |
raises CallFailed("…") | fails (call_failed), as when every attempt failed |
raises anything else, or returns None or another value | stops the run (below) |
Answer(files={name: file}, data=None) takes each file as bytes, an Output(kind=…, data=…), an
InputFile (to answer with a file the call was given) or a Path. Bytes take the kind the
capability gives that name: image is image/png, audio is audio/mpeg, video is
video/mp4. data is what the capability returns
(capabilities.md), with shortcuts:
Answer.json(value): astructured.generateanswer;Answer.turn(text, tool_calls): one agent turn, each tool call a mapping withnameandarguments(and anid, made up when it has none);Answer.submit(**arguments): an agent turn that submits its answer.
video.generate and mesh.generate answer data=None: FX reads the clip's size, frame rate and
duration, or the model's kind, from the file itself.
Faults stop the run. The function is part of your test, so it never fails quietly. Any other
exception (an AssertionError among them), an answer FX cannot read, or a stand-in host that
exits stops the whole run, rather than failing one call the workflow would then route around:
stopped the stand-in failed: AssertionError: …, exit 1. The function is not called again
after its first fault. Once the engine has ended, run() raises that exception in Python, noting
the run folder, and rejects with the value the function threw in JavaScript. A step's timeout:
and a stopped run abandon a call the function is still answering, and its late answer is
discarded.
Answers are checked as a provider's are, in this order:
- The request, before the function is called: a request that does not fit its capability
(
image.generate needs prompt) is refused,capability_refused, and the stand-in is not asked. - The answer's shape: the files the capability returns, by name and kind, and its
data(the answer holds no image,image.generate returns no file named mask,structured.generate returns its data as {"json": <value>}). - The route's check: the check of the FX adapter that would serve the route (the one its
contract's
adapternames, else the provider's default for the capability) when FX has one (built with no key, so it sends nothing), else the check the capability makes on every route. For a picture: a PNG of the exactsize, with an alpha channel whentransparentwas asked and no transparent pixel whenopaquewas (the image is 32x32, not 64x64). Forstructured.generate, its schema; for audio, an MP3. - The data: its round trip through canonical JSON, and an agent turn's shape.
The first refusal fails the call: call_failed, <capability> on <route> failed: <reason>. A call
that was refused or failed, by the stand-in or a check, is not asked again by the same command, and
nothing of it is recorded, so the next command asks again, whether it resumes the folder or starts
a new one. A picture of the wrong size is therefore a
failed call, not a picture your judge sees: to test a judge, answer a picture that passes these
checks and that the judge rejects.
Not checked yet: a route's own refusals of values, which its adapter makes just before sending:
a blank prompt, a size outside the route's range, an unknown background, too many pictures. A
stand-in may answer a request that a live run would refuse there.
Kept apart from your cache:
- The stand-in store. A stand-in run keeps its input files, calls, results and outputs in
stand-in/inside the project's cache (.fx/cache/stand-in/), and reads nothing of the cache itself. What your stand-in is asked never depends on what paid runs left in the cache, and a stand-in's answer never reaches a run without one. - Answers are kept there as a provider's are in the cache: running again, into the same folder
or a new one, asks only what has not been answered, whatever the function. A test can check
that a second run calls nothing, or that a resumed run asks only for what is left. Each test
should therefore start from a scratch copy of the project, as below, or delete
stand-in/first. - One mode per folder. The run folder is placed and named as any other. Its
plan.jsonhas"stand_in": trueand itsrun_startedeventsstand_in: true, as has eachcallevent the stand-in answered; the plan digest is the same as without a stand-in. Resuming the folder without--stand-in, or a plain run's folder with one, is refused (exit 1):refused: runs/one holds a stand-in run; resume it with --stand-in, or choose a new folder. - The takes file is not touched.
rerollandpickrefuse a stand-in run (exit 1), and a stand-in run writes nothing outside its folder and the stand-in store, apart from what--delivercopies out. A test of picks writes the takes file itself. - Read like any run.
inspectaddsstand-into its header,inspect --jsonhasrun.stand_in, andprojectshows therun_startedevent as it is.
A test. With pytest, for the icon workflow of
Getting started: the first drawing is opaque
everywhere, which its image.check_alpha judge rejects, so a second take is drawn.
# tests/test_icon.py
import io
import shutil
from collections import Counter
from pathlib import Path
import pytest
from PIL import Image
import grida.fx as fx
from grida.fx import DECLINE, Answer, CallRefused
PROJECT = Path(__file__).resolve().parents[1]
LANTERN = {"name": "copper lantern"}
def png(width, height, background_alpha):
"""An orange square on a background of the given alpha."""
picture = Image.new("RGBA", (width, height), (32, 24, 16, background_alpha))
picture.paste((200, 120, 40, 255), (width // 4, height // 4, width * 3 // 4, height * 3 // 4))
out = io.BytesIO()
picture.save(out, "PNG")
return out.getvalue()
@pytest.fixture
def project(tmp_path):
"""A scratch copy of the project, with a cache of its own and no takes: nothing starts
answered or picked."""
root = tmp_path / "project"
skip = shutil.ignore_patterns(".fx", ".venv", ".env", "runs", "tests", "*.takes.yaml")
shutil.copytree(PROJECT, root, ignore=skip)
return root
def test_a_rejected_icon_is_drawn_again(project):
asked = Counter()
def answer(call):
assert "copper lantern" in call.request["prompt"]
asked[call.take] += 1
width, height = map(int, call.request["size"].split("x"))
# Take 1 is opaque everywhere, which the judge rejects; take 2 has a clear background.
return Answer(files={"image": png(width, height, 255 if call.take == 1 else 0)})
result = fx.run("icon", inputs=LANTERN, cwd=project, stand_in=answer)
assert result.ok and result.stand_in and result.cost == 0
assert asked == {1: 1, 2: 1}
assert result.steps["clean"].facts["verdict"] == "accept"
again = fx.run("icon", inputs=LANTERN, cwd=project, stand_in=answer)
assert again.ok and asked == {1: 1, 2: 1} # answered from the stand-in store
def test_a_refused_drawing_fails_its_step(project):
def answer(call):
raise CallRefused("no lanterns today")
result = fx.run("icon", inputs=LANTERN, cwd=project, stand_in=answer)
assert not result.ok
assert result.failures["draw#1"].code == "capability_refused"
assert "no lanterns today" in result.failures["draw#1"].message
assert result.failures["clean#1"].skipped
def test_a_declined_drawing_is_not_live(project):
result = fx.run("icon", inputs=LANTERN, cwd=project, stand_in=lambda call: DECLINE)
assert result.failures["draw#1"].code == "not_live"
- The scratch copy gives each test an empty stand-in store, and no takes: a
rerollorpickyou made while following Getting started would change which take is drawn. A test of picks writes the takes file itself. - The test's own Python runs the judge, a built-in whose body is Python: the scratch copy has
no
.venv, sogrida.fx.runfalls back to the Python it runs in, which hasgridaand Pillow. - A failed
assertin the function fails the test with its own message: the run stops, andrun()raises theAssertionError. - One call at a time: FX calls the function for one call after another, in the order the
engine asks, never for two at once, so plain counters and
list.pop(0)scripts need no lock. Steps that run at the same time may still ask in either order.
The function in TypeScript
The same contract, with @grida/fx's names. A stand-in is a StandIn: a function of one
StandInCall, async or not, that returns an answer or DECLINE.
- The call has the members above. Its files are
InputFiles (.path,await .bytes(),.facts),call.filesis aReadonlyMapby digest, andcall.signalis aborted when FX abandons the call. FX asks one call at a time, so a function that ignores the signal holds up the calls after it. - An answer is a plain object,
{ files: { name: file }, data }, each file aUint8Array,{ bytes, kind }, anInputFile, or a fileURLwhose suffix gives its kind.Answer.json(value),Answer.turn(text, toolCalls)andAnswer.submit(arguments)are the shortcuts. A stand-in that asks another one first tellsDECLINEfrom an answer withisDecline(answer), which TypeScript narrows on (answer === DECLINEdoesn't). - Refusals are thrown:
throw new CallRefused("…")orthrow new CallFailed("…"). Anything else thrown, andundefinedor any other value returned, is a fault.
A file for --stand-in:
// tests/stand-in.ts
import { DECLINE, type StandIn } from "@grida/fx";
// A 1024x1024 PNG with a clear background, in pictures/ beside this file.
const LANTERN = new URL("./pictures/lantern.png", import.meta.url);
export const answer: StandIn = (call) =>
call.capability === "image.generate" ? { files: { image: LANTERN } } : DECLINE;
grida fx run icon --stand-in tests/stand-in.ts#answer -- --name "copper lantern"
The pytest above carries over to node:test call for call: run("icon", { inputs, cwd, standIn: answer }) on a scratch copy of the project, then result.standIn, result.chargedUsd
and result.failed. Its judge, image.check_alpha, is a built-in whose body is Python
(Getting started), so the test needs a Python with
grida: set GRIDA_FX_PYTHON, in the environment or in run's env.
Agents
A coding agent drives FX through the same command. It can write a workflow file and plan it:
a plan is free, so an agent can iterate on a workflow before spending anything. grida fx schema
gives it the JSON Schema of a workflow's inputs, and plan --json, inspect --json, expand and
price give it JSON to read. A run that may spend still needs --live and a ceiling.