Tutorials menu

Tutorial · Build & extend

Query CRM data with OQL

Answer real pipeline questions with OQL — filters, sorting, aggregates, grouping, and date helpers, run through an MCP-connected AI client.

Last updated Jul 17, 2026

It’s Monday morning and you have questions. What’s in the pipeline? How much is it worth? Where is it stuck? How did last quarter actually go? This tutorial answers all of them with OQL — OnePageCRM’s JSON query language — one question at a time, building from a simple filter to grouped aggregates with date helpers.

Where OQL runs today

OQL is available through the OnePageCRM MCP server, via its query tool. There is no public REST endpoint for OQL.

So to follow along, connect an AI client (Claude, ChatGPT, or your favorite agent) to https://app.onepagecrm.com/mcp. The MCP overview covers the endpoint and the supported clients — for those, there’s nothing to register.

Normally you’d just ask the agent a question in plain language and let it write the OQL itself. In this tutorial we write the queries by hand, because knowing the language lets you check the agent’s work, debug its misses, and reuse the same queries in your own integration later. To run a query verbatim, prompt the agent like this:

Run this exact OQL query with your query tool and show me the raw result: { "from": "deals", "where": { "status": "pending" } }

Now, the questions.

”What’s in my pipeline?”

Start with the open deals, biggest first. Every OQL query is a JSON object with one required key — from, the entity — plus optional clauses:

{
  "from": "deals",
  "select": ["name", "amount", "stage"],
  "where": { "status": "pending" },
  "order_by": [{ "amount": "desc" }],
  "limit": 10
}

Reading it clause by clause: where filters (a bare value means equals), select picks fields, order_by sorts (a single-key hash names the direction explicitly; a bare string field sorts ascending), limit caps the rows. The result:

{
  "rows": [
    { "name": "Initech expansion", "amount": 20000, "stage": 40 },
    { "name": "Acme renewal", "amount": 12000, "stage": 20 },
    { "name": "Globex onboarding", "amount": 8500, "stage": 20 }
  ],
  "row_count": 3
}

Every query returns this envelope: rows and row_count, plus a "truncated": true key that appears only when more rows exist beyond your limit — so you can never mistake a clipped answer for a complete one.

”How much is that worth?”

You don’t need the deal list to answer this — ask for aggregates instead of fields. OQL has seven (count(), sum, avg, min, max, median, percentile), and the workhorses are the first three:

{
  "from": "deals",
  "select": ["count()", { "sum": ["amount"] }, { "avg": ["amount"] }],
  "where": { "status": "pending" }
}

Without grouping, aggregates collapse the whole result set into exactly one row:

{
  "rows": [{ "_id": null, "count": 14, "sum_amount": 187500, "avg_amount": 13392.86 }],
  "row_count": 1
}

Aggregate columns are named <function>_<field>sum_amount, avg_amount — and a bare count() is just count. The "_id": null is the ungrouped row’s group key; ignore it.

Fourteen open deals, 187,500 in play. Next question.

”What’s due to close this quarter — and which are mine?”

Two new tools here. Date helpers like THIS_QUARTER() resolve in your profile’s timezone, so “this quarter” means your quarter. And ME() resolves to your own user ID, so the same query works for whoever runs it. Conditions in where are always ANDed together — there is no OR (use in for a set of values, or run separate queries):

{
  "from": "deals",
  "select": ["name", "amount", "expected_close_date"],
  "where": {
    "status": "pending",
    "owner_id": "ME()",
    "expected_close_date": "THIS_QUARTER()"
  },
  "order_by": ["expected_close_date"]
}
{
  "rows": [
    { "name": "Acme renewal", "amount": 12000, "expected_close_date": "2026-06-19" },
    { "name": "Initech expansion", "amount": 20000, "expected_close_date": "2026-06-30" }
  ],
  "row_count": 2
}

One subtlety worth knowing: pending deals carry an expected_close_date; once a deal is won or lost, its actual close_date is set instead. The deals entity reference flags which field applies when.

”Where is the pipeline sitting?”

For a breakdown rather than a list, add group_by. Pair it with aggregates, and remember the rule: every plain field in select must also appear in group_by:

{
  "from": "deals",
  "select": ["stage", "count()", { "sum": ["amount"] }],
  "where": { "status": "pending" },
  "group_by": ["stage"]
}
{
  "rows": [
    { "stage": 10, "count": 6, "sum_amount": 42000 },
    { "stage": 20, "count": 5, "sum_amount": 65500 },
    { "stage": 40, "count": 3, "sum_amount": 80000 }
  ],
  "row_count": 3
}

Stage numbers are account-configurable integers (and not necessarily sequential) — your agent can call the MCP context tool to map them to their labels.

”How did last quarter actually go?”

Switch the filter from open deals to closed ones and group by owner. LAST_QUARTER() does the date math for you:

{
  "from": "deals",
  "select": ["owner_id", "count()", { "sum": ["amount"] }],
  "where": { "status": "won", "close_date": "LAST_QUARTER()" },
  "group_by": ["owner_id"]
}
{
  "rows": [
    { "owner_id": "5417f36d1da4171227000001", "count": 9, "sum_amount": 96000 },
    { "owner_id": "5417f36d1da4171227000002", "count": 4, "sum_amount": 51500 }
  ],
  "row_count": 2
}

“Is that trend up or down?”

One quarter is a data point; a year is a trend. Two new pieces here. DAYS_AGO is the only date helper that takes an argument, so it uses object form: { "DAYS_AGO": [365] }. And grouping by a date field requires a bucketing function (DAY, WEEK, MONTH, QUARTER, YEAR) — OQL won’t group on a raw timestamp:

{
  "from": "deals",
  "select": ["close_date", "count()", { "sum": ["amount"] }],
  "where": {
    "status": "won",
    "close_date": { ">=": { "DAYS_AGO": [365] } }
  },
  "group_by": [{ "MONTH": ["close_date"] }]
}

Each row comes back keyed on the start of its month, with the count and revenue for that month — a closed-won trend line in one query. Note the operator syntax that appeared in where: when equality isn’t enough, wrap the value in a single-operator hash like { ">=": ... }. The full set (!=, <, >, in, between, like, null checks) is on the Operators page.

”Fine — who do I chase today?”

Analysis done, time to act. Actions are queryable too, and this is where ME() and TODAY() earn their keep — everything due today or overdue, assigned to you, still open:

{
  "from": "actions",
  "where": {
    "assignee_id": "ME()",
    "completed": false,
    "date": { "<=": "TODAY()" }
  }
}

No order_by — that’s deliberate. When you omit it, actions come back in Action Stream priority order, the same order the OnePageCRM app shows you. Your Monday list, pre-sorted.

One join without the joins

So far you’ve pulled raw IDs like owner_id and contact_id. Often you want the related record’s fields instead — the deal’s contact name, their company, their country. A lookup does that inline: reference a related entity’s field as <lookup>.<field> in select, where, or order_by.

{
  "from": "deals",
  "select": ["name", "amount", "contact.last_name", "contact.company"],
  "where": { "status": "pending", "contact.country_code": "US" },
  "order_by": [{ "amount": "desc" }]
}

Open US deals, each row carrying the deal and its contact’s name and company — no second query, no stitching IDs together afterwards. deals, actions, calls, and meetings look up their contact; notes look up both contact and deal. Grouping and aggregates stay on the entity’s own fields, so group by contact_id, not contact.company.

The guardrails

OQL’s limits are strict by design: a query that exceeds one fails with a clear error instead of silently returning a partial answer.

LimitValue
Max limit1,000 rows — higher values are rejected, not clamped
group_by fields3 max
like patterns3 wildcards and 100 characters max
where logicAND only — no OR

The schema works the same way: it’s an allowlist, so a typo’d field or an unsupported operator fails fast with a message that says exactly what’s wrong:

Unknown field 'foo' on entity 'contacts'
Operator '<' is not compatible with type 'string' on field 'first_name'

That strictness is what makes OQL safe to hand to an AI agent — it either gets a correct answer or an error it can read and fix. The full list is on Limits and errors.

Where to go next

  • OQL recipes — copy-paste queries for win rates, stuck deals, stale contacts, call volumes, and more.
  • Entity reference — every field on all seven core entities (contacts, companies, deals, actions, notes, calls, meetings), with filter/sort/aggregate flags.
  • Concepts — the full query shape and the rules behind aggregates and grouping.
  • MCP overview — the endpoint, the five tools, and how to connect a client that isn’t pre-registered.