Querying data

The MCP server and the REST API read the same data through one model: a collection, its fields, a filter, a sort and a page of results. Every rule on this page holds on both.

Open in ChatGPTOpen in Claudellms.txtllms-full.txtopenapi.json

Contents

How is the data organised?

A key reads one team, a team sees collections, a collection holds documents, and every document can hold values for that collection’s fields. One document is one job, one contractor, one plant. A collection is one kind of them.

WordWhat it is
TeamYour company’s account in Bidlo. One API key reads one team and nothing else.
CollectionOne kind of thing. For example, Projects holds jobs, Bid Items holds unit prices as bid, Contractors holds companies, and Facilities holds plants, pits and yards.
DocumentOne of them: one job, one company, one plant.
FieldOne thing a collection knows about each of its documents, such as a name, a date, a quantity, or a link to a document in another collection.
ValueWhat one document holds in one field.

The API reaches any collection your team can see in the app. List them with collections_list over MCP or https://app.bidlo.ai/api/v1/collections over REST. A collection is named by its id or by its name, and names match without regard to case. A name two collections share is a 422 that asks for the id, and a collection that does not exist is a 404.

A few of the app’s own collections, such as its pages and chats, are closed to the API. A key can also be limited to some collections when it is made. Either one is a 403.

How do I find the fields on a collection?

Call fields_list over MCP, or the collection’s fields route over REST: either returns every field your key can read, with its id, its name and its type. List the collections first, then ask for one collection’s fields.

The same call on both surfaces
GET https://app.bidlo.ai/api/v1/collections/<collection id or name>/fields
MCP tool: fields_list, with "collection_id"
KeyWhat it tells you
idThe field’s id, which never changes.
nameYour team’s name for the field.
field_typeHow the value is stored, such as text, number, date, tag, relation, location or state. The operator table below is keyed on it.
primary_keyWhether this is the field the document’s name comes from.
reference_idFor a relation, the collection its value points at. That is the collection to look a name up in.
descriptionWhat the field means.

A field is named by its id or its name everywhere a query takes one: in fields, filters, sorts and grouping, on both surfaces. Names match without regard to case. A name two fields share is a 422 that asks for the id, and a name that matches no field is a 422 that says Unknown field.

A team can rename a built-in field, so the same field can be called two things in two accounts. The id never changes. That is why no field id is printed on this page: read your own off fields_list, and treat every field name in an example here as an example. A name is fine for a one-off question. Code that has to keep working should send ids.

A field that links to a collection your key cannot read is left out of the list, and out of every result.

How do filters work?

A filter is a field, an operator and a value, one field takes one filter, and the field is named by its id or its name on both surfaces. Send them as an array; the operators a field will take depend on its type.

One query, over REST or MCP
POST https://app.bidlo.ai/api/v1/collections/<collection id or name>/documents/query
MCP tool: documents_query, with "collection_id" added to the same object

{
  "filters": [
    {
      "field_id": "Bid Date",
      "operator": "between",
      "value": { "start": "2026-10-01", "end": "2026-12-31" },
      "conjunction": "AND"
    },
    {
      "field_id": "<field_id of your County field>",
      "operator": "contains",
      "value": ["<document_id of that county>"]
    }
  ],
  "sorts": [{ "field_id": "Bid Date", "direction": "asc" }],
  "limit": 100
}

Over REST, send the query as a JSON body to the query route, or as JSON in the query string of a GET on the documents route. Over MCP, the same keys are the tool’s arguments.

A second filter on the same field is a 422 whose message says to combine the two, such as one between for a date window or eq with a list for options.

Each filter carries its own conjunction, either AND (the default) or OR, in capitals. Every OR filter joins one group, and that group is ANDed with the AND group. There is no nesting and no parentheses.

Field typeOperatorsValue
title, texteq, ne, contains, not_contains, starts_with, ends_withA string. eq is exact and case-sensitive; contains, starts_with and ends_with ignore case.
number, materialeq, ne, gt, lt, gte, lte, betweenA number, not a string; { start, end } for between.
dateeq, ne, gt, lt, gte, lte, between, relative_rangeAn ISO date or a keyword; { start, end } for between; { period, timeframe, number } for relative_range.
booleaneq, netrue or false, not a string. eq false includes empty values; ne false matches only stored true values.
tageq, ne, contains, not_containsAn array of option ids. A single id is read as an array of one.
tags, relation, peoplecontains, not_contains, eq, neAn array of document ids, or user ids for people. A single id is read as an array of one.
stateeq, contains, not_contains, neFull state names, such as ["Texas"], never the abbreviation.
locationwithin_distance, outside_distance, within_area, outside_area{ lat, lng, radiusInMeters } for distance; a GeoJSON Polygon or MultiPolygon as { geometry } for area.

Every type also takes is_null and is_not_null, with no value. ne includes empty values, booleans aside; for tags and people it means “does not contain” any of the ids. A list field is filtered as tags. Formula filters follow the result type, except that location formulas take no location operators and boolean formula eq false matches only stored false values.

Some values are checked before the query runs: a tag, tags, relation, people or state field needs a list of one or more ids or names, a text field a string, and a between a start and an end. A value that fails is a 422 that names the shape it wanted, empty list included. Number, date, yes-or-no and location values are not checked first. A blank value, such as an empty string, a between with an empty end or an area with no usable polygon, is dropped rather than applied, so a query that looks unfiltered usually is.

How do dates work?

A date value is an ISO date string or a keyword such as today, between takes a start and an end, and relative_range takes a period, a timeframe and an optional number. A letting window is a between; "the next three months" is a relative_range.

Three date filters
{ "field_id": "Bid Date", "operator": "gte", "value": "2026-10-01" }

{ "field_id": "Bid Date", "operator": "between",
  "value": { "start": "2026-10-01", "end": "2026-12-31" } }

{ "field_id": "Bid Date", "operator": "relative_range",
  "value": { "period": "next", "timeframe": "month", "number": 3 } }

eq on a date matches that whole day. Relative ranges use calendar boundaries in the team’s timezone.

Any of today, yesterday, tomorrow, last_week, next_week, last_month, next_month, last_year, next_year can stand in place of a date.

relative_range takes three keys. period is past, next or this; timeframe is day, week, month or year; and number is how many of that timeframe past and next reach back or forward, 1 if you leave it out. this ignores it.

How do I filter by a contractor, a county or an agency?

Those fields link to a document in another collection, so the value is that document’s id; find the id by querying that collection by name first. A county on a job is a document in a counties collection, the same as any other link.

Two calls: find the id, then filter on it
1. Find the county, in the collection your County field points at
   "collection_id": "<reference_id of your County field>"
   "filters": [{ "field_id": "Name", "operator": "eq", "value": "Travis County" }]

2. Filter on its id
   "filters": [
     { "field_id": "County", "operator": "contains", "value": ["<document_id of that county>"] }
   ]

The field’s reference_id in fields_list names the collection it points at. Query that collection with a filter on its name field, take the id off the document that comes back, and carry it into the filter. If the name matches more than one document, you choose which one you meant: the API does not guess.

A relation value is an array of ids, even when there is one of them, and a single id is read as an array of one.

How do I search near a place?

Filter a location field with within_distance and a value of lat, lng and radiusInMeters, where the radius is in metres. outside_distance is the same filter inverted.

Plants, pits and yards within 50 km of a point
"filters": [
  {
    "field_id": "Location",
    "operator": "within_distance",
    "value": { "lat": 30.2672, "lng": -97.7431, "radiusInMeters": 50000 }
  }
]

The radius is metres, so 50 km is 50000. A job’s own coordinates make the centre of the search when you are looking for who can reach it. is_null and is_not_null on the same field are how you tell which documents carry coordinates at all.

To test against a drawn area rather than a radius, use the area operators in the location row of the filter table, with a GeoJSON polygon as the value.

How do sorts work?

A sort is a field and a direction of asc or desc, several are allowed in the order you send them, and with none the newest documents come first. A sort names its field the same way a filter does: by id or by name.

Two sorts
"sorts": [
  { "field_id": "Bid Date", "direction": "asc" },
  { "field_id": "Name", "direction": "asc" }
]

They apply in the order of the array, so the first is the primary sort. One field takes one sort, and a second on the same field replaces the first. Sorting on a location field is a sort by distance and needs a point to measure from, which goes on the sort itself as location: { "type": "Point", "coordinates": [lng, lat] }.

How do pages work?

Ask for a limit of 1 to 500, 100 by default, then pass back the next_cursor each page returns, with the same query, until it comes back null. The limit and the cursor work the same over REST and MCP.

InputWhat it does
limit1 to 500, 100 by default.
cursorThe next_cursor from the page before. Send it with the same collection, the same limit, and the same filters and sorts.
pageA page number from 1, if you would rather count pages yourself.

next_cursor comes back in meta. It is null when a page comes back short, which is how you know you are done. A full last page still returns a cursor, and the page after it comes back empty.

A cursor sent with a different collection or limit is a 422, and so is a cursor sent with a page. The cursor does not carry your filters or sorts, so send them again with every page, unchanged: a page asked for with different ones is answered, not refused. A limit outside the range is refused rather than trimmed to fit. Neither surface returns a total count of matching documents. The cursor counts pages rather than holding a place, so documents added or changed while you read can shift between pages.

A grouping of a field and its type returns groups rather than a flat list: at most 500 documents in all, more being a 422, and each group holds only its first 50 documents, so a null next_cursor there does not mean you have everything. To read every document, query without grouping.

What comes back?

A page of documents, each with its id, its name and stored values keyed by field id, where the shape of a value follows the field’s type. REST and MCP answer with the same JSON; over MCP it is the text of the tool result.

One page of results
{
  "data": [
    {
      "id": "<document_id>",
      "name": "…",
      "collection_id": "<collection_id>",
      "created_at": "…",
      "updated_at": "…",
      "fields": {
        "<field_id>": {
          "value": "2026-10-14",
          "field_name": "Bid Date",
          "field_type": "date",
          "field_id": "<field_id>"
        }
      }
    }
  ],
  "meta": {
    "page": 1,
    "limit": 100,
    "collection_id": "<collection_id>",
    "next_cursor": "<cursor>"
  }
}

fields takes a list of field ids or names and returns those, plus any field you sort on. Leave it out to request every readable field. Missing values can leave keys absent; the primary field is the top-level name. Each returned value carries its field’s name and type beside it, so a result can be read without a second call.

fields also narrows a free-text query: the search looks only in the fields you asked for, any sort field and the name field. Leave fields out to search every field.

The shape of a value follows the field’s type.

Field typeValue
text, number, booleanAs stored, read as JSON where it can be: a number comes back as a number and a yes or no as true or false, and so does a text value that looks like one, such as 123.
dateAn ISO date string.
tag, a relation limited to one documentAn object with an id, a value and a document_id.
tags, a relation to manyAn array of those objects.
locationAn object that may include id, name, address, lat and lng. With coordinates, it also includes type: "Point" and coordinates: [lng, lat].
stateThe full state name as a string, such as "Texas". A value stored as an object comes back with an id, a name and an abbreviation.
peopleIds.

A link to a document your key cannot read is left out of the value rather than failing the read.

What happens when a query is wrong?

Validation errors return 422, but not every unsupported operator or value is caught before the query runs. Each of these is a 422.

  • A parameter the query does not take, such as team_id: the API key already carries its team.
  • A field name that matches nothing, or a field or collection name that matches more than one.
  • Two filters on one field, or a value rejected by the shape checks above.
  • On a GET, a fields, filters, sorts, grouping, page and limit that is not valid JSON.
  • A limit out of range, a cursor and a page together, or a cursor from another collection or limit.
  • A grouped answer over 500 documents.

An operator the table does not list for a type may be treated as equality or fail later. A collection outside your key’s reach is a 403, and one that does not exist, by id or by name, is a 404. Over MCP the same error comes back as a tool result marked isError. The REST API page lists every status.