Query Grammar
Agility has a compact, purpose-built syntax for choosing which attributes come back and for narrowing a result set. It is more expressive than it first looks: it supports attribute-path traversal across relations, downcasting to subtypes, aggregation rollups, filters applied part-way along a path, existence assertions, reusable variables and self-reference. Together these let a single expression walk the asset graph rather than requiring several round trips.
This syntax is not specific to one endpoint. The same attribute paths and filter tokens turn up across the API:
| Surface | Where the syntax appears |
|---|---|
rest-1.v1 | sel, where, sort and the rest, on the query string |
query.v1 | select, filter, sort and the rest, inside a JSON or YAML body |
api/asset | select, where, filter and sort on each command in a bulk request |
api/webhook | select, filter and with on each event-type subscription |
The fourth is easy to miss. A webhook subscription is not a query, but each event type it declares uses the same vocabulary to decide which assets it fires for and what to project into the delivered payload — so everything on these pages about attribute paths, filter expressions and filter context applies when you are writing a subscription. See event types.
What differs between them is the envelope — query string versus request body
— and the spelling of a few keywords. Webhooks take a subset: they filter and
project, but there is nothing to sort or page. The vocabulary itself is common, so an
attribute path such as Scope.Workitems:Story[ToDo='0'].Estimate.@Sum means
the same thing wherever it appears. Learn it once.
This section is the normative, human-readable description of that syntax. See which surface uses what for the keyword differences.
The Pages
| Page | Covers |
|---|---|
| Attribute paths | Dot notation, downcast :Type, aggregations .@Sum, now-only #, inline [filter] |
| Filter expressions | Comparison operators including ~=, and/or/grouping, value lists, unary existence, variables |
| Filter context | The with parameter and variable assignment |
| Sort and paging | +/- ordering, page=size,start, and reading the paging response attributes |
| Grammar reference | EBNF rendering of all three grammars, plus the raw .g files to download |
Parameter Map
Every parameter below is read from the query string of a rest-1.v1
request.
| Parameter | Purpose | Grammar | Notes |
|---|---|---|---|
sel | Attribute selection | Attribute paths | select is an undocumented alias that takes precedence over sel |
select | Alias for sel | as sel | Not mentioned by the grammar at all |
where | Filter expression | Filter expressions | This is the filter parameter. There is no filter= on rest-1.v1. |
with | Filter-context variable assignment | Filter context | $VAR=a,b|$VAR2=c — values are not quoted here |
sort | Ordering | Sort and paging | + ascending (the default), - descending, comma-separated |
page | Paging | Sort and paging | page=<size>,<start> — size comes first |
find | Text search | none | Absent from the grammar entirely |
findin | Attributes that find searches | none | Absent from the grammar. Optional; defaults to Name + ShortName |
asof | History moment | none | Absent from the grammar. Accepts an integer moment or a datetime |
deleted | Include deleted assets | none | Boolean flag. Absent from the grammar files. Defaults to off on every instance |
needTotal | Compute the total attribute on the response | none | Boolean flag. Without it a paged response omits total entirely (unpaged responses always carry a real one). Its default is per-instance |
The two flags look alike but their defaults do not behave alike, which is worth knowing before you rely on either:
| Flag | Default when omitted | Varies by instance? |
|---|---|---|
deleted | Off — hardcoded | No |
needTotal | falls back to the ApiDefaultNeedTotal server setting | Yes |
So deleted behaves identically everywhere, and needTotal does not.
total at allThe default for needTotal comes from the instance-configurable
ApiDefaultNeedTotal setting, so a client that pages off total can work
against one instance and break against another. Send needTotal=true
explicitly, and treat a missing total as "not computed", not as zero. See
Sort and paging.
asof Accepts Two Different Things
asof accepts either an integer moment number or a datetime. The
datetime is parsed with invariant culture, so use ISO 8601 rather than a
localized format. Both of these are valid, and they mean different things:
?asof=113 # moment number 113
?asof=2005-08-03T17:30:00.00 # the state as of that instant
A datetime asof returns the last historical record on or before the
instant given. A value that parses as neither
raises 400 Bad Request.
Which Parameters Apply to Which Request Shape
This is not stated by the grammar, and it is a common source of "my filter was ignored" reports. The three request shapes build different queries:
| Request shape | sel | where | with | sort | page | find | asof | needTotal |
|---|---|---|---|---|---|---|---|---|
/Data/{Type} (a list) | yes | yes | yes | yes | yes | yes | yes | yes |
/Data/{Type}/{id} (a single asset) | yes | no | yes | no | no | no | yes | yes |
/Hist/{Type}[/{id}] (history) | yes | yes | yes | yes | yes | yes | yes | yes |
Read the middle row carefully. /Data/Story/1364 is not a URL where "the
list parameters don't apply" — it is a URL where deleted, needTotal, asof,
sel and with all take effect, while where, sort, page and find are
discarded without comment. Same URL, same query string, two different fates, no
error either way.
The single-asset query is built from the OID with only the deleted flag, the
total flag and a moment slice. No filter is ever attached, and nothing anywhere
reports that one was supplied and ignored. ?where= on this URL is not an
error, not a warning, and not applied — it is simply as though you never sent
it.
Two further consequences worth knowing:
- The grammar agrees about filtering — "only valid when asking for a list of assets" — but says nothing about the parameters that do survive.
- On a history request with no
sort, the server adds an implicit ascending sort onMoment. Supplying your ownsortreplaces it, so a history response is not guaranteed chronological once you sort by something else.
If the URL addresses a single attribute — /Data/Member/20/Name — the selection
is that attribute and sel is not consulted at all.
# operator must be percent-encodedThe now-only operator in an attribute path is #, and it must be sent as
%23. Unencoded, it is the URI fragment delimiter: your client truncates the URL
there and never puts the rest on the wire, so the server answers a query you did
not write. Depending on where the truncation lands you get either a 200 with a
silently wrong result set or a 400. The fault is client-side, so debugging the
server will not explain it. See
Attribute paths.
Selection Defaults, and Why You Get Attributes You Did Not Ask For
Two selection defaults explain most surprises about response size.
Omitting sel does not return everything. It returns
assettype.GetBasicAttributeDefinitions() — a per-asset-type basic set. Never
assume an unfiltered request is exhaustive; if you need a specific attribute,
name it.
Selecting a relation implicitly selects more. After your selection is
parsed, every attribute whose type is Relation additionally selects, on the
related asset:
- its designated name attribute;
- its designated short-name attribute, where the type declares one — and
the designation names a real attribute, so it surfaces as
Owners.NicknameorWorkitems.Number, never as a literalShortName; - the relation's DisplayBy attribute, where the relation declares one.
Each of the three is conditional and they frequently coincide — DisplayBy and
the short-name designation are usually the name attribute again — so the common
case is exactly one extra attribute: ?sel=Status really returns Status plus
Status.Name. This is why responses contain attributes you never
requested, and it is the first thing to look at when tuning payload size — the
expansion is per relation attribute selected, so a selection with many relations
grows faster than it looks.
Combined with the absence of a default page size, these two defaults are the main reason Agility integrations pull more data than intended. See Query size and performance.
Grammar Versus Implementation
The published grammar files were last revised in 2012 and describe the intended syntax rather than the shipped implementation. Where the grammar and the server disagree, the server wins. These are the known disagreements:
| # | The grammar says… | The server actually… |
|---|---|---|
| 1 | The filter root rule is filter2_token | Reads the query parameter where. Filter2 is an internal type name that never appears on the wire; there is no filter= parameter on rest-1.v1 |
| 2 | Selection comes from sel | Also accepts select, an alias that wins when both are present |
| 3 | Nothing about find, findin, asof, deleted or needTotal | Supports all five |
| 4 | Selection is exactly what you asked for | Expands every relation selection with up to three conditional attributes — commonly just Name |
| 5 | Nothing about the default selection | Falls back to a per-type basic set, not to all attributes |
| 6 | paging_token : page_size (',' page_start)? | Agrees — size first, then start |
| 7 | sort_token has no default | Adds an implicit ascending Moment sort on history requests when sort is absent |
| 8 | Filtering, sorting and paging are properties of a query | Are not applied at all on single-asset /Data/{Type}/{id} requests, while deleted and needTotal are |
Item 6 is listed because it is frequently gotten backwards, not because it is a disagreement — all three sources agree that size comes first.
Which Surface Uses What
These three surfaces share the attribute-path and filter-expression vocabulary
and differ in envelope and keyword spelling. Webhook subscriptions are left out of
the table because they are not a query surface — they support select, filter
and with but have no from-and-return semantics, no sort and no paging. See
event types for the fields they do accept.
| Aspect | rest-1.v1 | query.v1 | api/asset |
|---|---|---|---|
| Envelope | GET, parameters on the query string | POST, a YAML document in the body (JSON is valid YAML) | POST, a JSON command or array of commands |
| Root asset type | The URL path — /Data/Story | from: Story (an as alias parses but is silently discarded) | from — an asset type or an OID token |
| Selection | sel or select, one comma-separated token | select or sel, a list; nested mappings are subqueries (an entry's as alias parses but is silently discarded) | select, a list |
| Filter expression | where=<expression> | filter: — a scalar or list of expressions in the same language | filter — an array of expressions, as on query.v1 |
| Equality shorthand | not available | where: — a mapping of attribute to a single scalar value | where — an object, as on query.v1 |
| Filter context / variables | with=$V=a,b|$W=c | with: — a mapping of name to a scalar or a list | uses aliases instead |
| Sort | sort=-Estimate,+Name | sort: ['-Estimate', '+Name'] | sort, a list |
| Paging | page=<size>,<start> (positional) | page: { size: 50, start: 0 } (named; both keys required) | page: { start, size } (named; start defaults to 0) |
| Text search | find, findin | find, findin | find is command-dependent — see below |
| History | asof, plus the separate /Hist namespace | asof | not applicable |
| Deleted assets | deleted=true | deleted: true | IsDeleted per command — the ?deleted=1 URL flag does not surface deleted rows on its own |
| Total count | needTotal=true | needTotal: true | needTotal: true |
| Grouping | not available | group: — a list of expressions, and grouping subqueries | not available |
| Several queries per request | no | yes — the document root may be a sequence of query mappings | yes — the body may be an array of commands |
| Writes | op for operations; POST a payload to update | not available — update and execute are stripped | update and execute on any command |
find does not always mean text searchOn rest-1.v1 and query.v1, find searches text attributes and pairs with
findin.
On api/asset its meaning depends on the command it appears in. In an update
command, find is half of the
find-and-replace form — it names the
substring to be replaced and pairs with replace, not with findin.
In an api/asset query command, find is a text search, as on
query.v1: it pairs with findin.
The practical caution: a query carrying find that is ported into a Bulk API
update command will be read as a replacement instruction, not a search.
where does not mean the same thing in both APIsOn rest-1.v1, where is the full filter language. On query.v1, where is a
plain equality mapping and the full filter language lives under filter. So
?where=ToDo='0' and filter: ["ToDo='0'"] are equivalent — but
where: { ToDo: '0' } on query.v1 is the shorthand, not the expression.
Conflating the two is the single most common mistake when porting a query from
one surface to the other.
Two further query.v1 details worth knowing:
- The keys
updateandexecuteare stripped from the document before validation, so including them is neither honoured nor rejected. - Any other unrecognised key is rejected with
Invalid key at node(s): …when key validation is enabled.
Errors
A malformed value for any of these parameters produces 400 Bad Request with an
Error payload whose Message names the parameter — for example
Invalid SEL parameter, with a nested Exception element carrying the detail
such as Unknown AttributeDefinition: Story.Bogus. The full list is in
HTTP status codes.
Machine-Readable Grammar
The three ANTLR grammar files are published verbatim, license headers intact, at stable URLs — fetch them if you are building a parser, linter or query builder:
V1Query.g— selection, attribute paths, filter expressions, sortV1FilterContext.g— thewithparameterV1Paging.g— thepageparameter
They are transcribed as EBNF on the grammar reference page. Read them as a syntax map, not as an oracle — see the divergences above.