19 KiB
Building queries with the graphiti CLI
The base UI-bundle template ships @salesforce/graphiti as a devDependency, exposing a
graphiti CLI. Its sf-gql-* subcommands turn a small JSON spec into a schema-correct
GraphQL document — the query string, its typed variables, and a TypeScript shape — with
every platform guardrail (@optional, value/displayValue wrappers, edges/node,
pagination, the mutation Record envelope) already applied. This is the preferred way to
author the GraphQL in Read workflow /
Write workflow step 2 — you get a query that's already
grounded against the org's live schema instead of hand-writing one and discovering field
errors at runtime.
The CLI is a query compiler, not a data fetcher. Every
sf-gql-*command returns a{ query, variables, types, warnings }envelope. It never calls Salesforce for records and never returns rows — execution still happens at runtime throughsdk.graphql!.query()/.mutate()exactly as the Read/Write workflows describe. Think of it as "codegen for the query string itself": dev-time you compile the operation, persist it, generate types; runtime the SDK runs it.
Fallback only when the CLI genuinely can't run. Fall back to the schema-grep path (
bash <skill-dir>/scripts/graphql-search.sh <Entity>) and hand-author per graphql-hand-authoring.md only when@salesforce/graphitiisn't installed or the org can't be primed (SCHEMA_PRIME_FAILED). The guardrails there are the same ones the CLI automates — you're just applying them by hand.A primed CLI returning empty / "not found" /
Cannot query fieldis NOT a fallback trigger. That's a fact about the org — a wrong API name, or metadata that isn't deployed/refreshed — not a CLI failure. Re-discover(list before describe),sf-gql-connect --forceRefreshif you just deployed, or deploy the metadata; do not switch to the script or hand-author around it, and never editschema.graphqlto make the name resolve (it grants no org access — see graphql-hand-authoring.md).
MCP, later. These
sf-gql-*commands mirror, one-for-one, thesf_gql_*tools of the graphiti MCP server (same args, same output, different transport). If graphiti is later approved as an MCP server, an agent calls thesf_gql_*tools directly and everything below about shapes and behavior still holds — only the invocation changes.
How to invoke it
Run from the UI bundle dir (where package.json with the @salesforce/graphiti dep
lives). Each command takes one JSON argument (positional, or piped on stdin) and emits exactly
one JSON line on stdout:
npx graphiti sf-gql-list '{"org":"myOrgAlias","object":"Account","fields":["Name","Industry"],"first":10}'
# stdin form (handy for large specs):
echo '{"org":"myOrgAlias","object":"Account","fields":["Name"]}' | npx graphiti sf-gql-list
Always single-quote the JSON argument. It contains " and may contain $varName tokens
(see Variables); an unquoted $foo is expanded by the
shell to an empty string before graphiti ever sees it.
org is an org alias from the local Salesforce CLI auth (~/.sf / ~/.sfdx) — the same
aliases sf org list shows. It is required on every command.
Exit code is 0 on success, 1 on any error (the error is also in the JSON envelope, so you
can parse stdout rather than branch on the code).
The three phases
1. Prepare — make sure the org's schema is available
The build commands auto-prime: the first sf-gql-* call for an org downloads and caches
its schema, so you usually don't need a separate step. You only call sf-gql-connect
explicitly to refresh after a deploy (new objects/fields/picklist values won't appear until
you do):
npx graphiti sf-gql-connect '{"org":"myOrgAlias","forceRefresh":true}'
# → {"org":"myOrgAlias","instanceUrl":"https://…","refreshed":true,"cached":false,"durationMs":…}
If priming fails, the command returns a SCHEMA_PRIME_FAILED error envelope (see
Errors) — the org is unreachable, unauthed, or hitting a server-side
introspection issue. You can't build verified queries against an org you can't prime; surface
that to the user rather than guessing field names.
2. Discover — never guess an object or field
sf-gql-discover is how you ground intent against the org before building. Three modes:
# What objects exist (optional substring filter)?
npx graphiti sf-gql-discover '{"org":"myOrgAlias","mode":"list_objects","search":"Account"}'
# → {"mode":"list_objects","objects":[{"name":"Account"}, …]}
# What fields does an object have (+ type, filterable, sortable, picklist values)?
npx graphiti sf-gql-discover '{"org":"myOrgAlias","mode":"describe_object","object":"Hero__c"}'
# → {"mode":"describe_object","object":{"name":"Hero__c","fields":[
# {"name":"Class__c","label":"Class","type":"PICKLIST","filterable":true,"sortable":true,
# "picklistValues":["Warrior","Mage","Rogue","Cleric"], …}, …]}}
# Drill into one field
npx graphiti sf-gql-discover '{"org":"myOrgAlias","mode":"describe_field","object":"Hero__c","field":"Class__c"}'
mode is required; object is required for describe_object/describe_field; field
is required for describe_field. Use this output to pick exact API names, valid picklist
values, and which fields are filterable/sortable — the same facts that otherwise cause silent
runtime failures.
3. Build — compile the operation
Pick the command for the task, pass the spec, read the query + variables + types out of
the envelope. The next section is the catalogue.
The commands
| Command | Builds | Key spec fields |
|---|---|---|
sf-gql-list |
List query (uiapi.query) |
object, fields[], first?, filter?, orderBy?, parentFields?, childRelationships?, scope? |
sf-gql-detail |
Single-record-by-Id query | object, fields[], idVariable? (default id) |
sf-gql-aggregate |
Aggregate query (uiapi.aggregate) |
object, groupBy?[], aggregations?[], filter?, first? |
sf-gql-create |
Create mutation | object, returnFields? (default ["Id"]), inputVariable? (default input) |
sf-gql-update |
Update mutation | object, returnFields?, inputVariable? |
sf-gql-delete |
Delete mutation | object, inputVariable? |
sf-gql-raw |
Arbitrary query from CLI-style select/set/var commands |
commands[], operation? (query|mutation|aggregate) |
sf-gql-discover |
Schema metadata (no GraphQL) | mode, object?, field?, search? |
sf-gql-connect |
Primes/refreshes the schema cache (no GraphQL) | forceRefresh? |
operationName (most commands) overrides the generated operation name — set it to something
meaningful (e.g. "GetActiveHeroes") so the persisted .graphql file and the types codegen
generates off it read well. Defaults are derived (<Object>List, <Object>Detail, …).
Output envelope
Every build command emits the same four-key envelope:
{
"query": "query …{ … }", // the GraphQL document — paste verbatim
"variables": [{ "name": "after", "type": "String", "required": false }],
"types": "export interface …", // TS shape of variables + result (a preview)
"warnings": [] // schema/semantic warnings — READ THESE
}
query— paste it verbatim into your.graphqlfile or inlinegql. The guardrails are baked in; don't "tidy" them out (that's the load-bearing part — see Primed vs degraded).variables— the GraphQL variables the operation declares, with types and nullability. These map straight to thevariablesobject you pass tosdk.graphql!.query({ query, variables }).types— a TypeScript preview of the variables + result shape, with anonymized interface names (S37eaResult). Useful to see the shape, but the canonical typed path is still the bundle'snpm run graphql:codegenover your saved.graphql(it produces named types you import) — see Wiring into runtime.warnings— non-fatal, but always read them. An empty[]means the operation validated cleanly against the live schema. A non-empty entry usually means a field/object isn't in the primed schema or a selection is semantically off (examples below).
Ground-truth examples
These are real outputs captured from the CLI (query strings pretty-printed for readability; the CLI emits them as a single JSON line).
sf-gql-list — {"org":"…","object":"Hero__c","fields":["Name","Level__c","Class__c"],"first":5,"orderBy":{"Level__c":{"order":"DESC"}}}
query Hero__cList($after: String) {
uiapi {
query {
Hero__c(first: 5, after: $after, orderBy: { Level__c: { order: DESC } }) {
edges {
node {
Name @optional { value displayValue }
Level__c @optional { value displayValue }
Class__c @optional { value displayValue }
}
}
pageInfo { hasNextPage endCursor }
}
}
}
}
variables: [{ "name": "after", "type": "String", "required": false }] — note the CLI adds
forward-pagination ($after + pageInfo) for you. orderBy is a singleton object
({Field:{order:DESC}}), not an array or {field,direction}.
sf-gql-detail — {"org":"…","object":"Hero__c","fields":["Name","Level__c"]}
query Hero__cDetail($id: ID!) {
uiapi { query { Hero__c(where: { Id: { eq: $id } }, first: 1) {
edges { node {
Name @optional { value displayValue }
Level__c @optional { value displayValue }
} }
} } }
}
Injects $id: ID! and the where: { Id: { eq: $id } }, first: 1 binding. Pass
idVariable to rename $id.
sf-gql-aggregate — {"org":"…","object":"Hero__c","groupBy":["Class__c"],"aggregations":[{"function":"count","field":"Id","alias":"total"},{"function":"avg","field":"Level__c","alias":"avgLevel"}]}
query Hero__cAggregate($after: String) {
uiapi { aggregate { Hero__c(groupBy: { Class__c: { group: true } }, after: $after) {
edges { node { aggregate {
Class__c { value }
total: Id { count { value } }
avgLevel: Level__c { avg { value } }
} } }
pageInfo { hasNextPage endCursor }
} } }
}
groupBy is ["Field"] (or [{field,function}] for date bucketing); aggregations carry a
function (count/countDistinct/sum/avg/min/max), an optional field (defaults to
Id for counts), and an alias. Observed quirk: this exact spec returns
warnings: ["Validation: Field \"avg\" must not have a selection since type \"Double\" has no subfields."] — avg over a Double should be selected bare, not avg { value }. Treat such a
warning as a prompt to adjust the selection, not as a failed build.
sf-gql-create — {"org":"…","object":"Hero__c","returnFields":["Id","Name"]}
mutation CreateHero__c($input: Hero__cCreateInput!) {
uiapi { Hero__cCreate(input: $input) { Record {
Id
Name @optional { value displayValue }
} } }
}
The types field describes the input you must supply at runtime — note the entity-keyed
wrapper, the single most common mutation-variables mistake:
interface Hero__cCreateInput { Hero__c: Hero__cCreateRepresentation; }
interface Hero__cCreateRepresentation { Name?: string; Class__c?: string; Level__c?: number; … }
// → variables: { input: { Hero__c: { Name: "Aria", Level__c: 5 } } }
sf-gql-update is identical but the input type adds a sibling Id: string
({ input: { Hero__c: {…}, Id: "a0X…" } }). sf-gql-delete uses the generic
RecordDeleteInput { Id: string } ({ input: { Id: "a0X…" } }) and selects only Id back —
there is no per-entity delete type. Mutation inputs are raw values — never {value}-wrapped
(that wrapper is a read-shape thing; mirroring it into a write is the classic failure).
One mutation guardrail the builder does NOT add:
allOrNone. The emitted document wraps the operation in a bareuiapi { … }, notuiapi(input: { allOrNone: … }). Before you ship, add theallOrNonewrapper yourself and set it explicitly —uiapi(input: { allOrNone: true }) { Hero__cCreate(input: $input) { Record { … } } }— per guardrail 4 in the spine and the hand-authored templates. graphiti gives you theRecordoutput envelope and the entity-keyed input shape; the transaction policy is still on you.
You do not pass record values to the builder. A create/update spec takes only
object,returnFields?,inputVariable?, andoperationName?— there is nofieldskey carrying the values to write. The builder emits the mutation shape (with the input declared as a$variable); the actual values become the runtime variable you pass tosdk.graphql!.mutate({ mutation, variables }).returnFieldsonly controls what you read back after the write.
sf-gql-raw — for shapes the declarative commands don't cover, drive select/set/var:
npx graphiti sf-gql-raw '{"org":"…","commands":["select uiapi/query/Hero__c/edges/node/Name/value","set uiapi/query/Hero__c first=5"]}'
Same @optional/wrapper guardrails are still applied automatically.
Primed vs degraded — why the guardrails sometimes vanish
This is the one behavior that surprises people, and it changes how much you can trust a single
build. The guardrail automation (@optional, value/displayValue, edges/node, the typed
result shape) is conditional on the object being in the primed schema. When it is, you get
the clean output above. When it isn't — wrong API name, not deployed, or schema not yet
refreshed after a deploy — the CLI still emits a query, but a degraded one, and flags it:
{"org":"…","object":"Account","fields":["Name","Industry"],"first":3} against an org whose
cache doesn't contain Account:
query AccountList($after: String) {
uiapi { query { Account(first: 3, after: $after) {
edges { node { Name Industry } } # ← bare fields: no @optional, no value/displayValue
pageInfo { hasNextPage endCursor }
} } }
}
…with types collapsing the result to Account: unknown and:
"warnings": ["Validation: Cannot query field \"Account\" on type \"RecordQuery\"."]
So: a non-empty warnings array — especially Cannot query field … — means the build is
NOT trustworthy. Do not ship a degraded query. Re-discover the correct API name, or
sf-gql-connect with forceRefresh: true if you just deployed, then rebuild until
warnings is []. A clean build is your signal that the guardrails actually fired.
Never edit schema.graphql to make a degraded build pass — the mirror is a generated,
read-only reflection of the org; adding the missing field/type there silences the warning but
grants no org access, so the operation still fails at runtime. Fix the name or deploy + refresh.
Variables — parameterising a query
A $varName string leaf inside filter is promoted to a typed, nullable GraphQL variable,
with the type inferred from the schema:
{"org":"…","object":"Hero__c","fields":["Name"],"filter":{"Class__c":{"eq":"$heroClass"}},"first":5}
query Hero__cList($after: String, $heroClass: Picklist) {
uiapi { query { Hero__c(first: 5, after: $after, where: { Class__c: { eq: $heroClass } }) { … } } }
}
# variables: [ {after, String, false}, {heroClass, Picklist, false} ]
What does not work on the declarative tools: passing a whole-arg $var for filter or
orderBy (e.g. "filter":"$where"). Those args are typed as objects and reject a bare string
with an INVALID_ARGS error — promote at the leaf, not the whole argument. first is a
number and likewise can't take a $var string.
Wiring the output into runtime
The CLI produces the operation; the rest is the existing Read workflow / Write workflow, unchanged:
- Persist the
querystring — inlinegqlfor simple ops, or a.graphqlfile (one operation per file, imported with?raw) for complex ones. Use a meaningfuloperationNameso the file/type names read well. - Codegen —
npm run graphql:codegen(from the UI bundle dir) generates named types intosrc/api/graphql-operations-types.ts. (The CLI'stypesfield is a preview of the same shape; codegen is the canonical import source.) - Call —
sdk.graphql!.query({ query, variables })for reads,sdk.graphql!.mutate({ mutation, variables })for writes, using the codegen'd types and thevariablesthe CLI listed. Surface decision (!vs guard), error handling, and caching are exactly as the SKILL spine and sdk-api.md describe.
The CLI never executes anything — it has no part in step 3. It just makes step 1 produce a query you can trust.
Error envelope
Failures come back as a single JSON line and set exit code 1:
{"error":{"code":"INVALID_ARGS","message":"Input failed schema validation.","details":[{"path":["mode"],"message":"Required"}]}}
code |
Means | Do |
|---|---|---|
INVALID_ARGS |
The spec failed Zod validation (missing/mis-typed field). details[] gives the JSON path. |
Fix the spec per details — e.g. add the required mode, or stop passing a $var where an object is expected. |
AUTH_FAILED |
Org alias not authed / not found in local CLI auth. | Check sf org list; have the user authenticate the alias. |
SCHEMA_PRIME_FAILED |
Schema couldn't be downloaded/introspected (unreachable, or a server-side introspection error). | Retry; if it persists the org has an introspection problem — fall back to graphql-search.sh + hand-authoring, and tell the user. |
INTERNAL |
Anything else; verbatim message preserved. | Read the message. Set GRAPHITI_DEBUG=1 for a stack trace. |
Common pitfalls (failure-first)
| Symptom | Cause | Fix |
|---|---|---|
Built query has bare fields, no @optional/wrappers; types shows unknown |
Object not in the primed schema (typo, not deployed, stale cache) | Check warnings for Cannot query field; re-discover the name or connect --forceRefresh, rebuild until warnings: [] |
$heroClass came through as an empty string |
Shell expanded $… in an unquoted arg |
Single-quote the whole JSON argument |
INVALID_ARGS on "filter":"$where" |
Whole-arg $var not allowed on declarative tools |
Promote at a leaf: "filter":{"Field":{"eq":"$where"}} |
| Mutation rejected at runtime / wrong shape | {value}-wrapped a mutation input, or dropped the entity-key wrapper |
Inputs are raw values under the entity key: { input: { <Object>: { Field: v } } }; read the types field |
Field "avg" must not have a selection… warning |
Selected avg { value } on a Double |
Select the aggregate bare per the warning |
| New field/object missing from discover or build | Schema cached before the deploy | sf-gql-connect with forceRefresh: true, then rebuild |
Used graphiti new/cd/select/run and it executed against the org |
That's the legacy interactive/session flow, not the sf-gql-* mirror |
Use the stateless sf-gql-* commands documented here — they compile, they don't execute |