How-to › Expose your API to agents

How to design filter and sort arguments for a list tool#

Named properties, a query mini-syntax, or a filter object with sort enums: three argument designs for one list tool, scored on ten prompts, then made to fail loudly.

Audience
API producer
Level
intermediate
Topic
Design tool schemas agents can use
Languages
TypeScript and JavaScript
Verified

Read first: Shape a tool result so an agent can reason about it

An agent asks your list tool for the open tickets owned by two people and gets back an empty array. Its call was well formed, so the tool ran it: the single owner property took one name, or the query string took or for a word in a title. The agent reads the empty list as the answer, reports that nothing matches, and moves on.

What you get

You will end up with three argument designs for one list tool, a harness that scores them on ten prompts, and a handler that reports what it applied and rejects unknown keys. This is for you if agents call a list tool you own.

Short answer

Give the list tool a filter object with one key per field you index, lists where several values may match, and a sort object whose field and direction are enums. Set additionalProperties to false at every level, so an unknown key returns an error naming the key instead of an empty list. Return the applied filters beside the rows. A query mini-syntax expresses more, but a syntax slip comes back as an empty result unless the parser refuses it.

You will need

Node 22 or later, and a list or search tool with at least one filter. Verified 2026-10-21 against Node 22.22.0 and Ajv 8.20.0. The designs are written as MCP tool definitions, whose inputSchema is a JSON Schema, and Ajv validates every call against them. The sample runs offline over twelve tickets and calls no model.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A documented query-string mini-syntaxFilters that combine freely, with exclusions and alternatives in one callA grammar to spell out in the description, a parser to keep strict, and an empty result for every slip the parser lets throughThe filters are a handful of fields with fixed vocabularies
A filter object with sort field and direction enumsSeveral fields, several values per field, and a store with a fixed set of sortable columnsThe largest schema of the three on every turn, and no exclusion without another property to carry itOne filter, where a single flat property is shorter and as clear
Named filter properties in the schemaTwo or three filters that never combine as alternatives, on a tool that already existsOne property per operator you add, one value per field per call, and a free-text sort that reaches any columnUsers ask for two owners, or for everything except one state

The query string is the smallest schema here, 700 characters against 773 and 1,021, because its grammar lives in one description rather than in properties. It is also the only design that expresses all ten prompts. The parser is what it costs, and a slip in the grammar costs the agent an empty list.

Write the three schemas against one store

The store is twelve tickets in tickets.json, with an index on created_at, priority, and title. Each design is a tool definition with the same name and a different inputSchema, so an agent sees one at a time. The filter object carries the most structure.

filter: {
  name: 'tickets_list',
  description: 'List tickets. Every key in filter narrows the result, and a list matches any of its values. Sort by one indexed column.',
  inputSchema: {
    type: 'object',
    properties: {
      filter: {
        type: 'object',
        properties: {
          status: { type: 'array', items: { type: 'string', enum: STATUSES }, description: 'Any of these states.' },
          owner: { type: 'array', items: { type: 'string' }, description: 'Any of these owner logins.' },
          priority: { type: 'array', items: { type: 'string', enum: PRIORITIES }, description: 'Any of these priorities.' },
          created_after: { type: 'string', pattern: DATE, description: 'Only tickets created after this date, YYYY-MM-DD, exclusive.' },
          text: { type: 'string', description: 'Words that must all appear in the title, in any order.' },
        },
        additionalProperties: false,
      },
      sort: {
        type: 'object',
        properties: {
          field: { type: 'string', enum: SORT_FIELDS, description: 'The indexed columns, which are the only ones you can sort by.' },
          direction: { type: 'string', enum: ['asc', 'desc'] },
        },
        required: ['field', 'direction'],
        additionalProperties: false,
      },
    },
    additionalProperties: false,
  },
},

status and priority are arrays over an enum, so “open or in progress” is one call, and the closed vocabulary lets the agent write “not closed” as the two states that remain. owner is an array of free strings, because logins are an open set. Excluding an owner therefore stays out of reach: the agent cannot list the complement of a set it has never seen. sort.field is an enum of the three indexed columns, the only place the store’s indexes appear in anything the agent reads.

The named design flattens the same fields into one property each and leaves sort as a free string. The query design has one property, whose description is the grammar. Anthropic’s tool definition guidance asks for at least three to four sentences of description, and for format-sensitive input it offers an input_examples field. An MCP tool definition has no such field, so the JSON Schema examples keyword carries them here.

query: {
  type: 'string',
  description: [
    'Clauses separated by spaces, all of which must match. A clause is field:value with field one of',
    'status (open, in_progress, closed), owner (a login), priority (low, normal, high) or created_after (YYYY-MM-DD, exclusive).',
    'Prefix a clause with - to exclude it. Join two clauses on the same field with OR, in capitals, to match either.',
    'Bare words must all appear in the title. sort:FIELD-DIRECTION orders the result, with FIELD one of',
    'created_at, priority, title and DIRECTION asc or desc.',
  ].join(' '),
  examples: ['status:open owner:ana sort:created_at-desc', 'invoice export -status:closed'],
},

Every field with a fixed vocabulary lists it, the date says whether it is inclusive, and the one operator with a casing rule says so. The Lucene query parser documentation is blunt about the rest. A parser of this kind is designed for human-entered text, and a field with a limited set of values belongs in a typed clause rather than in a string to be parsed.1

Score the designs on the same ten prompts

A prompt means a set of rows, in an order when it asks for one. prompts.json carries that intent in the store’s own query shape, so the intended rows are computed rather than typed, and calls.json carries one call per prompt per design. A call scores when it returns exactly the intended rows.

/** ok, or what came back instead: extra rows, missing rows, nothing, or an error. */
export function score(intended, result) {
  if (result.error) return 'error'
  const got = result.rows
  if (got.join() === intended.join()) return 'ok'
  if (!got.length) return 'empty'
  const extra = got.filter((id) => !intended.includes(id)).length
  const missing = intended.filter((id) => !got.includes(id)).length
  return extra || missing ? `${extra ? `+${extra}` : ''}${missing ? `-${missing}` : ''}` : 'order'
}
node measure.mjs
prompt                                      named   query   filter
open tickets                                ok      ok      ok
tickets owned by ana                        ok      ok      ok
open tickets owned by ana or ben            -2      ok      ok
high priority tickets, newest first         ok      ok      ok
tickets created after 2026-03-01            ok      ok      ok
closed tickets not owned by ben             +1      ok      +1
tickets about the invoice export            ok      ok      ok
ana's open tickets, highest priority first  ok      ok      ok
everything that is not closed               -3      ok      ok
open tickets, oldest first                  ok      ok      ok
intended rows first time                    7/10    10/10   9/10
schema size in characters                   773     700     1021

The calls were written by hand as the most literal correct call for each prompt. The table therefore measures what each design can express, not how often a model fills it in correctly, and the query design’s ten of ten assumes every clause was spelled right. To measure the agent, record its calls from ten real prompts and drop them into calls.json; the harness scores whatever is there. That is the loop Anthropic’s guidance on writing tools for agents describes.

Named properties hold one value per field, so “ana or ben” loses two rows and “not closed” loses three. The filter object has lists and no negation, so “not owned by ben” returns one closed ticket too many. That cell is the trade-off in miniature: a property per operator, or an operator the agent has to spell.

Report what you applied, and refuse what you do not know

Every result carries applied_filters, the normalized filter the store ran, beside the rows. With it, the agent that sent owner:ana or owner:ben can see "text":["or"] and correct itself. Without it, the empty list is all the evidence it has.

JSON Schema’s additionalProperties, set to false, fails validation for any key that properties does not name, and Ajv reports which one in params.additionalProperty. The handler turns that into an error the agent can act on.

/** One Ajv error as data an agent can act on: the key, where it sat, and what was allowed. */
export function explain(e) {
  const path = e.instancePath.replace(/^\//, '').replace(/\//g, '.') || 'arguments'
  if (e.keyword === 'additionalProperties') {
    const allowed = Object.keys(e.parentSchema.properties ?? {})
    const key = e.params.additionalProperty
    return { code: 'unknown_key', key, path, allowed, message: `unknown key "${key}" in ${path}; allowed keys are ${allowed.join(', ')}` }
  }
  if (e.keyword === 'enum') {
    const allowed = e.params.allowedValues
    return { code: 'invalid_value', path, allowed, message: `${path} must be one of ${allowed.join(', ')}` }
  }
  return { code: 'invalid_argument', path, message: `${path} ${e.message}` }
}

An enum miss gets the same treatment, with the allowed values in the error. OpenAI’s strict function calling requires the same additionalProperties: false on every object, plus every property in required. The MCP specification reaches for the keyword on a tool with no parameters at all.2

Make the parser refuse a guess

The lenient parser in query-syntax.mjs is the one that gets written first: anything that is not a recognized clause is a word to look for in the title. The strict mood refuses any token it cannot honor, and names it: a lowercase operator, a comparator, a quoted phrase, a minus on a word, or an OR with no clause after it.

if (strict && /^(or|and|not)$/i.test(token)) {
  return fail('bad_operator', token, `"${token}" is not an operator; write OR in capitals between two clauses`)
}
if (strict && /[:=<>]/.test(token)) {
  return fail('bad_clause', token, `"${token}" is not a clause; a clause is field:value with field one of ${FIELDS.join(', ')}`)
}
if (strict && orPending) {
  return fail('bad_operator', token, 'OR joins two clauses on the same field, as in owner:ana OR owner:ben')
}
if (strict && !/^[a-z0-9]+$/i.test(token)) {
  return fail('bad_word', token, `"${token}" is not a title word; a word is letters and digits, with no quotes and no leading -`)
}
push(where, 'text', token.toLowerCase())

Stripe’s search API takes the same position and returns an error for an unsupported operator rather than an empty page.3 The strictness extends to values: status:Open is refused with the three values that would have worked. A field given twice, owner:ana owner:ben, is refused too, because a space means AND in this grammar and OR in Lucene’s. The cost is the one every strict parser carries. Text with a colon, a quote, or a hyphen in it cannot be searched until the grammar learns quoting or the tool grows a text field.

Check it worked

The first test is the one the design turns on: a malformed filter returns an error naming the offending key, not an empty array.

test('an unknown filter key is an error that names the key, not an empty list', () => {
  const result = call('filter', { filter: { owner_name: 'ana' } }, { store })
  assert.equal(result.rows, undefined)
  assert.equal(result.error.code, 'unknown_key')
  assert.equal(result.error.key, 'owner_name')
  assert.match(result.error.message, /owner_name/)
  assert.ok(result.error.allowed.includes('owner'))
})
node --test tools.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 211.815321

The last test asserts the counts in the table, 7, 10, and 9, so a schema change that alters what a design can express fails here before it reaches an agent.

When it goes wrong

The query design answers a slip with an empty list and no complaint. Both slips below are guesses the grammar invites: a lowercase or, and a comparator borrowed from GitHub’s syntax, where created:>2026-03-01 is how a date bound is written.4 The lenient parser reads each as a title word, and the strict one names it.

node pitfall.mjs
query "status:open owner:ana or owner:ben"
  lenient rows: []
  lenient applied_filters: {"status":["open"],"owner":["ana","ben"],"text":["or"]}
  strict error: "or" is not an operator; write OR in capitals between two clauses
query "created>2026-03-01"
  lenient rows: []
  lenient applied_filters: {"text":["created>2026-03-01"]}
  strict error: "created>2026-03-01" is not a clause; a clause is field:value with field one of status, owner, priority, created_after
sort by a column with no index
  named design, sort "updated_at desc": {"rows":12,"plan":"full sort of 12 rows, no index on updated_at"}
  filter design, sort.field "updated_at": sort.field must be one of created_at, priority, title

The sort pitfall is the second half of that output. The named design’s free string let updated_at desc through, and the store did a full sort on a column with no index. That is instant on twelve rows, but the same full sort is the query that times out on twelve million. The filter design never let the request reach the store, and its error lists the three columns that would have worked.

When not to do this

Do not build a mini-syntax for three fields with fixed vocabularies. Lucene’s documentation says that enumerable values belong in typed clauses, and an agent generating a query string is the program-generated text that note warns about.

Do not reject an unknown key without naming the keys that exist. An error that says only “invalid arguments” leaves the agent to guess again, and the second guess is as likely to be wrong as the first.

Do not let a sort argument name a column your store cannot serve. The enum on sort.field is the list of indexed columns, and keeping the two in step is a maintenance duty cheaper than a timeout the agent cannot explain.

Do not read the ten of ten as a verdict on agents. The table measures what each design can express with a correct call. A transcript of real calls measures the agent, and that is the number to decide on, because the design that expresses the most has the most ways to be wrong.

Last verified

Verified 2026-10-21 against Node 22.22.0 and Ajv 8.20.0. Every output block is what the command preceding it printed. The ten calls per design in calls.json were written by hand as the most literal correct call for each prompt, so the counts measure what each design expresses. No model was run.

Footnotes

  1. The query parser syntax page cautions, before it explains a single operator, that the parser is designed for human-entered text, not for program-generated text. A field with a limited set of values belongs in a TermQuery clause rather than in a string to be parsed, it adds. The Boolean operators must be ALL CAPS, which is the rule the lowercase or on this page broke. Agents were not the program the authors had in mind. They are the program the advice fits. ↩︎ Back to text

  2. The MCP specification recommends { "type": "object", "additionalProperties": false } as the input schema for a tool that takes no parameters, glossing it as explicitly accepting only empty objects. The keyword is at work before there is a single property to protect. ↩︎ Back to text

  3. Stripe’s search query language allows up to ten clauses, joined with AND or with OR but never both in one query, and offers no parentheses. An unsupported operator, such as a greater-than on a string field, returns an error. The whole grammar fits in a few lines, which is about the size a grammar has to be before an agent can hold it from a description. ↩︎ Back to text

  4. GitHub’s search syntax excludes a qualifier with a leading hyphen and a keyword with NOT, which works only for string keywords, not for numerals or dates. The issues documentation adds that boolean and nested queries are available on a repository’s issues page, so one search box is read by two grammars according to where it was typed. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.