Querying data
Open in ChatGPTOpen in Claudellms.txtllms-full.txtopenapi.json
Contents
Developer docs
How is the data organised?
A key reads one team, a team sees collections, a collection holds documents, and every document carries a value for each of that collection’s fields. One document is one job, one contractor, one plant. A collection is one kind of them.
| Word | What it is |
|---|---|
| Team | Your company’s account in Bidlo. One API key reads one team and nothing else. |
| Collection | One kind of thing: Projects holds jobs, Bid Items holds unit prices as bid, Contractors holds companies, Facilities holds plants, pits and yards. |
| Document | One of them: one job, one company, one plant. |
| Field | One 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. |
| Value | What one document holds in one field. |
The MCP server reads every collection your team can see. The REST API names projects, contractors and facilities in its path, https://lite.bidlo.ai/api/v1/{collection_id}, and nothing else. The two name a collection differently as well: over REST it is the alias in the path, over MCP it is the collection’s name on query_database_data and its id on get_collection_fields. Names match case-insensitively over MCP, trimmed, and singular or plural.
How do I find the fields on a collection?
Call the MCP tool get_collection_fields with the collection ids you care about: it returns every field with its id, its name, its type and the operators that field will take. List the collections first with get_team_collections, then pass their ids in.
| Key | What it tells you |
|---|---|
| field_id | The id a REST filter or sort names the field by. |
| name | Your team’s name for the field, which is what an MCP filter names it by. |
| field_type | How the value is stored: text, number, date, tag, relation, location, state. |
| filter_field_type | How the field is filtered, which is what the operator tables below are keyed on. |
| primary_key | Whether this is the field the document’s name comes from. |
| reference_collection_id | For a relation, the collection its value points at. That is the collection to resolve a name in. |
| description, aka | What the field means, and other names it goes by. |
The same answer carries the operator table for every filter type, the value shape for every operator, and the formula syntax, so one call tells an agent everything it needs to write a filter. Three field types are filtered as something else: a formula is filtered as whatever it works out to, a relation limited to a single document is filtered as a tag, and a type with no table of its own is filtered as text.
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 get_collection_fields, and treat every field name in an example here as an example. It is also the fastest way to get ids for a REST call, because REST has no route that lists fields today. The other way is to read the field_id off any value in a REST response, which every value carries.
Coming. Two REST routes will list collections and their fields: /api/v1/collections and /api/v1/collections/{collection_id}/fields. Both are marked as coming in /openapi.json. Neither is live today. Until they are, field ids come from get_collection_fields over MCP.
How do filters work?
A filter is a field, an operator and a value, and one field takes one filter; over REST the field is named by its id, and over MCP by its name. Send them as an array; the operators a field will take depend on its filter type.
"filters": [
{
"field_id": "<field_id of your Bid Date field>",
"operator": "between",
"value": { "start": "2026-10-01", "end": "2026-12-31" },
"conjunction": "AND"
}
]"filters": [
{
"field_name": "Bid Date",
"operator": "between",
"value": { "start": "2026-10-01", "end": "2026-12-31" }
}
]Four rules hold on both sides. One field takes one filter: over REST a second filter on the same field is a 400 whose message says to combine the two, and over MCP there is no error at all: the last filter on that field quietly wins. is_null and is_not_null take no value. A filter whose value is empty (null, a blank string, an empty array, a half-filled { start, end }) is dropped rather than applied, so a query that looks unfiltered usually is. And a value in the wrong shape is a 400 that names the shape it wanted.
One rule holds on one side only. Over REST each filter carries its own conjunction, either AND (the default) or OR: every OR filter joins one group, and that group is ANDed with the AND group. There is no nesting and no parentheses. Over MCP there is no conjunction at all and every filter is ANDed.
The operators, in full: is_null, is_not_null, eq, ne, contains, not_contains, starts_with, ends_with, gt, lt, gte, lte, between, relative_range, within_distance, outside_distance. Which of them a given field takes, and what its value must look like, is below.
title, text
A name, a description, a number written as words. The value is always a string. Over MCP, eq with a string value on a text field is treated as contains.
| Operator | Value |
|---|---|
| eq, ne, contains, not_contains, starts_with, ends_with | A string. |
| is_null, is_not_null | No value. |
number, material
A quantity, a price, an estimate. The value is a number, not a string.
| Operator | Value |
|---|---|
| eq, ne, gt, lt, gte, lte | A number. |
| between | { start, end }. REST only. |
| is_null, is_not_null | No value. |
date
A letting date, a bid date, an award date. The value is an ISO date string or a keyword. See the dates section below for the three shapes.
| Operator | Value |
|---|---|
| eq, gt, lt, gte, lte | An ISO date string, or a keyword. |
| ne | An ISO date string, or a keyword. REST only. |
| between | { start, end } |
| relative_range | { period, timeframe, number } |
| is_null, is_not_null | No value. |
boolean
A yes or no. The value is a boolean, not the string "true".
| Operator | Value |
|---|---|
| eq, ne | true or false. |
tag
One option off a list, such as a status. A relation that is limited to a single document is filtered as a tag too.
| Operator | Value |
|---|---|
| eq, ne | The option’s document id. Over REST, an array of ids; over MCP, a name or an id. |
| is_null, is_not_null | No value. |
tags, relation, people
A link to one or more documents in another collection: the contractor on a job, the county it sits in, the agency that let it. A people field holds user ids instead.
| Operator | Value |
|---|---|
| contains, not_contains, eq, ne | Document ids. Over REST, an array of ids; over MCP, a name, an id, or an array of either. |
| is_null, is_not_null | No value. |
state
A state. The value is the full name, not the two-letter abbreviation.
| Operator | Value |
|---|---|
| contains, not_contains | Full state names, such as ["Texas"]. |
| is_null, is_not_null | No value. |
location
An address with coordinates, such as a plant or a yard. REST also takes within_area and outside_area with a GeoJSON polygon; see the REST API page for that shape.
| Operator | Value |
|---|---|
| within_distance, outside_distance | { lat, lng, radiusInMeters } |
| is_null, is_not_null | No value. |
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 a number. A letting window is a between; "the next three months" is a relative_range.
{ "field_name": "Bid Date", "operator": "gte", "value": "2026-10-01" }
{ "field_name": "Bid Date", "operator": "between",
"value": { "start": "2026-10-01", "end": "2026-12-31" } }
{ "field_name": "Bid Date", "operator": "relative_range",
"value": { "period": "next", "timeframe": "month", "number": 3 } }Both surfaces take today, yesterday, tomorrow in place of a date. REST also takes last_week, next_week, last_month, next_month, last_year, next_year.
relative_range takes three keys. period is past, next or this; timeframe is day, week, month or year; and number is required for past and next, which is how many of that timeframe to reach back or forward. this needs no number.
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; over MCP you can send the name instead and the server resolves it for you. A county on a job is a document in the Counties collection, the same as any other link.
"filters": [
{ "field_name": "County", "operator": "contains", "value": "Travis County" }
]"filters": [
{
"field_id": "<field_id of your County field>",
"operator": "contains",
"value": ["<document_id of that county>"]
}
]Over MCP the server looks a name up in the collection the field points at, which is the one named by reference_collection_id on the field, and uses the match. A value that is already an id is used as given. If the name matches more than one document, the call comes back with an error naming the matches and telling you to resolve it yourself.
That is what resolve_documents_by_name is for, and it is also how you get ids for a REST call. Give it the referenced collection’s id and either a query to search on or a list of names to look up, and it answers with exact matches and near matches, each as an id and a name. It reads the first 10 names you send and returns 1 to 25 rows per name, 10 by default. It needs one of the two inputs: with neither, it errors.
Carry the ids you get back into the filter. Over REST a relation value is always an array of ids, even when there is one of them, and a bare 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.
"filters": [
{
"field_name": "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.
REST can also filter a location against a drawn area rather than a radius. The REST API page carries that shape.
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 over REST, by name over MCP.
REST "sorts": [{ "field_id": "<field_id of your Bid Date field>", "direction": "asc" }]
MCP "sorts": [{ "field_name": "Bid Date", "direction": "asc" }]Send several and they apply in the order of the array, so the first is the primary sort. One field takes one sort. Sorting on a location field is a sort by distance and needs a point to measure from, which REST takes on the sort itself. With no sorts at all the newest documents come first.
How do pages work?
Ask for a limit and a page number, starting at page 1; neither surface returns a total count, so you read until a page comes back short. The two surfaces allow different sizes.
| limit | page | Knowing when you are done | |
|---|---|---|---|
| MCP | 1 to 100, 25 by default | From 1, up to 20 | has_more and next_page come back with the results |
| REST | 1 to 2000, 100 by default | From 1, echoed back in meta | Nothing is returned: read on until a page comes back short |
A limit outside the range is refused rather than trimmed to fit: over REST it is a 400, and over MCP it is an input validation error on the tool call. Neither surface returns a total count of matching documents, and neither has a cursor, so paging is a page number you raise until the results run out.
What comes back?
A page of documents, each with its id, its name and one value per field, where the shape of a value follows the field’s type. The two surfaces key a document’s fields differently: REST by field id, MCP by field name.
{
"data": [
{
"id": "<document_id>",
"name": "…",
"collection_id": "<collection_id>",
"created_at": "…",
"fields": {
"<field_id>": {
"value": "2026-10-14",
"field_name": "Bid Date",
"field_type": "date",
"field_id": "<field_id>"
}
}
}
],
"meta": { "page": 1, "collection_id": "<collection_id>" }
}{
"collection_id": "<collection_id>",
"collection_name": "Projects",
"page": 1,
"selected_fields": [{ "field_id": "<field_id>", "field_name": "Bid Date" }],
"documents": [
{
"document_id": "<document_id>",
"document_name": "…",
"fields": { "Bid Date": "2026-10-14" }
}
],
"returned_row_count": 1,
"has_more": true,
"next_page": 2
}Over MCP, ask for the fields you want by name in return_fields. Leave it out and a capped default set of up to 8 fields comes back with a warning saying so, which is rarely the set you wanted. A text value longer than 400 characters is cut short. Over REST, fields takes a list of field ids and does the same job; leave it out and every field comes back.
The shape of a value follows the field’s type. These are the REST shapes; over MCP a linked document comes back as an id, a name and a mention.
| Field type | Value |
|---|---|
| text, number, boolean | As stored. |
| date | An ISO date string. |
| tag, a relation limited to one document | An object with an id, a value and a document_id. |
| tags, a relation to many | An array of those objects. |
| location | An object with an id, a name, an address, a lat and a lng. |
| state | An object with an id, a name and an abbreviation. |
| people | Ids. |
One advanced case changes the shape of the whole answer: REST takes a grouping of a field id and a field type, and the response then comes back as groups of documents rather than a flat list, with page selecting groups. The REST API page covers it.
Where do I go next?
The MCP server page has the tools and their inputs, the REST API page has the one route and its parameters, and the access page has the key. Everything on this page is true of both; the reference pages carry what is true of one.
- MCP server: the five tools, what each takes and returns, how to connect a client, and how errors come back.
- REST API: the one route, its parameters, the response envelope and the status codes, plus /openapi.json.
- Access and keys: how to get a key and how to send it.
If a read you need is not in the model above, mail support@bidlo.ai.
A Bidlo account is required. Anyone can get one by booking a call at https://cal.com/matt-wolfe-yecrho/30min — Bidlo sets the team up, and a team owner or admin then mints the API key in Settings → Features → API. There is no public tier and no self-serve signup.
## For agents: how to use Bidlo data - Read Bidlo data through its MCP server at https://lite.bidlo.ai/api/mcp — streamable HTTP, no OAuth. - Get a Bidlo account first: all Bidlo data needs one, anyone can get one by booking a call at https://cal.com/matt-wolfe-yecrho/30min, and there is no public tier or self-serve signup. - Authenticate with the header `Authorization: Bearer sk_live_...`; the REST API takes the same key. - A team owner or admin mints that key in the app under Settings, then Features, then API. - Read the REST reference at https://www.bidlo.ai/openapi.json, the guide at https://www.bidlo.ai/developers, and its reference pages: https://www.bidlo.ai/developers/querying (filters, operators, dates, places, pages), https://www.bidlo.ai/developers/mcp (the five tools), https://www.bidlo.ai/developers/rest (the route) and https://www.bidlo.ai/developers/access (keys). - Prefer the MCP tools over REST; use `query_database_data` with `within_distance` for anything shaped like "near X". - Call `get_collection_fields` before composing a filter — it returns the operators each field takes. - Never invent a Bidlo unit price. Read one off the bid items, or say the forecast is not available.