Atlas

Atlas for power users

The atlas command line in the Desktop sandbox — schema, describe, count, query, agg and export, with the output it actually prints.

Ask Atlas and the query console answer questions. The atlas command answers them in a shell, where you can pipe the result into jq, loop over ten entities, or drop the output into a script that runs again tomorrow. It's a read-only, typed surface: no SQL goes in, and nothing you type can write to a source.

You get it in the Desktop sandbox — take control of the terminal and it's on your PATH. It's also exactly what Corint runs when it answers from your data, so anything on this page is something you can ask the agent to run for you.

Every command prints JSON on stdout with one fixed shape. Keys are always present, null when unknown. The outputs below are real, from a Postgres source called Meridian ERP. They print as a single line — here they're wrapped for reading, and where one is shortened, a marks the cut.

The loop

schemadescribecount / query / agg. Skipping the middle step is the usual way to waste a run: describe is what tells you a field's real name, its type, and which conditions are legal on it.

atlas schema — what exists

$ atlas schema
{"sources": [{"name": "Meridian ERP", "kind": "postgres", "status": "ok",
              "error": null, "checkedAt": "2026-08-18T12:20:14.794Z",
              "entities": 8, "lastDiscoveredAt": "2026-08-18T11:08:16.956Z"}],
 "entities": [
   {"name": "Customer", "source": "Meridian ERP", "group": "Sales & CRM", "rows": 120},
   {"name": "InventoryItem", "source": "Meridian ERP", "group": "Inventory & Supply Chain", "rows": 750},
   {"name": "OrderLineItem", "source": "Meridian ERP", "group": "Sales & CRM", "rows": 11107},
   {"name": "SalesOrder", "source": "Meridian ERP", "group": "Sales & CRM", "rows": 3000},
   {"name": "Shipment", "source": "Meridian ERP", "group": "Operations & Logistics", "rows": 2798},
],
 "total": 8, "matched": 8, "offset": 0}

Corint running atlas schema in a chat, with the raw output and a summary table of all eight entities and their row counts

One call gives you every source in the organization with its live connection status, and every entity you can read, with its row count and group. Access is per field: an entity shows up once you can read one of its fields, and total counts what you hold rather than what exists. See Who can see what. On a big graph, filter instead of paging: atlas schema --match orders searches names, tables, sources, groups, field names, descriptions and sampled values. Entities page at 50 — matched and offset tell you when you've seen it all.

status is the source's health at the moment you asked, and matched: 0 means your terms found nothing, not that the data is missing. A source Atlas couldn't open reads unreachable, and error carries the reason in words: an upload that won't parse or a connection string that isn't valid names itself there instead of arriving as an unexplained failure.

atlas describe — what a field will accept

$ atlas describe SalesOrder
{"entity": "SalesOrder", "source": "Meridian ERP", "table": "orders",
 "group": "Sales & CRM",
 "description": "order_number is a human-readable identifier with format SO-YYYYMM-NNNN … status is a
   low-cardinality code … subtotal_idr, discount_idr, tax_idr, and total_idr are monetary amounts in
   Indonesian Rupiah with no stated unit; total_idr should equal subtotal_idr - discount_idr + tax_idr.",
 "rows": 3000, "countError": null,
 "grain": {"rows": 3000, "field": "id", "distinct": 3000},
 "reportHeader": null,
 "fields": [
   {"name": "order_date", "type": "date", "nullable": false, "key": null,
    "ops": ["eq", "neq", "gt", "gte", "lt", "lte", "isnull", "notnull", "in", "nin"],
    "samples": ["2025-01-01", "2025-01-01"], "description": ""},
   {"name": "status", "type": "string", "nullable": false, "key": null,
    "ops": ["eq", "neq", "gt", "gte", "lt", "lte", "isnull", "notnull", "in", "nin",
            "includes", "startswith"],
    "samples": ["delivered", "delivered"], "description": ""},
],
 "links": {"out": [{"from": "SalesOrder.customer_id", "to": "Customer.id", "origin": "fk", "orphanRate": 0},
                   {"from": "SalesOrder.warehouse_id", "to": "Warehouse.id", "origin": "fk", "orphanRate": 0}],
           "in":  [{"from": "OrderLineItem.order_id", "to": "SalesOrder.id", "origin": "fk", "orphanRate": 0},
                   {"from": "Shipment.order_id", "to": "SalesOrder.id", "origin": "fk", "orphanRate": 0}]},
 "totalFields": 10, "matchedFields": 10, "offset": 0}

This is the densest command, and the one to read carefully.

  • ops is the legal condition list for that field, per field. An op that isn't listed is rejected, not ignored.
  • grain is measured, not assumed: 3,000 rows, 3,000 distinct id — one row per order. On a line-level entity this is what stops you summing an order total once per line.
  • links carries the relationships in both directions, with the share of rows that point at nothing (orphanRate).
  • description is what the enrichment pass wrote, including the units question — here, amounts in Rupiah that the columns themselves never label. It is blank on an entity you can read only part of, because the prose quotes field names and value ranges; grain goes null the same way when you cannot read the field it counts.
  • reportHeader is whatever a spreadsheet printed above its table, verbatim, when the source is a sheet.

--fields <substring> narrows a wide entity to matching fields (one substring, not a list); fields page at 60.

atlas count — exact totals, several at once

$ atlas count SalesOrder Shipment Customer
{
  "counts": [
    {
      "entity": "SalesOrder",
      "source": "Meridian ERP",
      "count": 3000,
      "error": null
    },
    {
      "entity": "Shipment",
      "source": "Meridian ERP",
      "count": 2798,
      "error": null
    },
    {
      "entity": "Customer",
      "source": "Meridian ERP",
      "count": 120,
      "error": null
    }
  ]
}

Counts run server-side over the whole set, and several entities sweep in one call. Add --json '{"and": [...]}' to count matching rows instead of all of them. If a count fails, that element carries an error and a null count — a null is not a zero.

atlas query — rows, as a page

$ atlas query SalesOrder --json '{"and": [{"field": "status", "op": "eq", "value": "delivered"}],
    "sort": [{"field": "order_date", "dir": "desc"}], "limit": 3,
    "fields": ["order_number", "order_date", "status", "total_idr"]}'
{"rows": [{"order_number": "SO-202608-0066", "order_date": "2026-08-13T00:00:00.000Z",
           "status": "delivered", "total_idr": "31846900"},
          {"order_number": "SO-202608-0055", "order_date": "2026-08-11T00:00:00.000Z",
           "status": "delivered", "total_idr": "1300500"},
          {"order_number": "SO-202608-0053", "order_date": "2026-08-10T00:00:00.000Z",
           "status": "delivered", "total_idr": "1978400"}],
 "count": 3, "source": "Meridian ERP", "truncated": true, "total": null,
 "warning": "truncated page: 3 of unknown matching rows. Totals or aggregates from this page are
   wrong — use atlas count/agg for whole-set figures, or page with sort+offset."}

Note what the envelope says about itself. truncated: true means these rows are a page, not the set, and the warning spells out the mistake it's guarding against. The limit defaults to 1,000 and tops out at 10,000; to page further, add offset — and offset without sort is refused, because unsorted pages can repeat or skip rows.

Filters take three shapes and no others: value ops (eq neq gt gte lt lte contains includes startswith) carry value, member ops (in nin) carry values, and isnull / notnull carry neither. Groups inside or are ANDed internally and ORed together. join attaches fields from a related entity; unless a filter names one of those fields, every origin row comes back and the unmatched ones carry null.

A join hop names four things: the origin entity, the referencing field on it, the target entity, and a key on the target. It does not need a relationship the graph already lists. Two entities in different sources join the same way. Atlas measures the target key before it joins, refuses the hop if that key repeats, and caps how many rows it will move.

When your filter values contain quotes or unicode, read the query from stdin instead of fighting the shell: atlas query SalesOrder < query.json.

atlas agg — the whole set, grouped

$ atlas agg SalesOrder --json '{"groupBy": ["status"],
    "measures": [{"fn": "count"}, {"fn": "sum", "field": "total_idr", "as": "revenue"}],
    "sort": [{"field": "revenue", "dir": "desc"}]}'
{
  "rows": [
    { "status": "delivered", "count": 2749, "revenue": "62123197500" },
    { "status": "cancelled", "count": 150, "revenue": "3004142700" },
    { "status": "shipped", "count": 49, "revenue": "1088358700" },
    { "status": "processing", "count": 35, "revenue": "699820300" },
    { "status": "pending", "count": 17, "revenue": "327702700" }
  ],
  "path": "native",
  "truncated": false,
  "total": 5,
  "caveats": [],
  "source": "Meridian ERP"
}

This is the command people skip and shouldn't. It aggregates over every matching row on the source, so the counts sum to the entity's full 3,000 — a query page would have summed three orders and looked just as confident.

  • measures — one to five of count, sum, avg, min, max, count_distinct. Name them with as; sort refers to that output name, never to a source column. Over a filter that matches nothing, count and count_distinct answer 0 on every source kind, and sum, min and max answer null.
  • groupBy — up to three fields; a date field takes a grain (year, quarter, month, day).
  • total — the number of groups, which is why an ungrouped grand total reports 1 however many rows it summed. It is null exactly where the result carries windowed: true, which says the groups really were cut to your window, so the ones cut away were never counted. A top-N that had nothing to cut, because the set was smaller than the window, reports the real number and carries no windowed flag at all.
  • caveats — NULL groups, orphans, truncation. Part of the answer, not noise.

A sort on a measure plus a limit is a top-N, and the group budget below never applies to it unless offset + limit passes 1,000. Every source Atlas can scan answers one: a source that aggregates for itself is asked first, and an overflow past the budget is ordered and cut in Atlas's DuckDB instead. A native aggregate, Salesforce or a connector serving /aggregate, is pushed measures only; wherever Atlas's own engine does the grouping, a group key sorts as well as a measure.

$ atlas agg TransactionItem --json '{"groupBy": ["product_id"],
    "measures": [{"fn": "count", "as": "n"}],
    "sort": [{"field": "n", "dir": "desc"}], "limit": 5}'

The group budget is 1,000, and it bounds the groups that have to come back whole: an aggregate with no ordered window to cut them by, or a source that groups for itself but cannot window what it grouped. Over it, the command refuses rather than silently truncating: ask for the top-N, filter first, or group coarser.

When it refuses

A rejected command exits non-zero and prints one error object whose diagnostics name the fix. Asking for an op that doesn't exist:

$ atlas query Customer --json '{"and": [{"field": "segment", "op": "ne", "value": "retail"}]}'
{"error": {"code": "compile-rejected",
           "diagnostics": [{"code": "value-shape",
             "message": "Ops are eq, neq, gt, gte, lt, lte, contains, includes, startswith, in, nin,
               isnull, notnull. Use `includes` for substring matching and `in` for a set — there is no
               `like`, `between` or `regex`. Only the ops `atlas describe` lists for a field are legal
               on it."}]}}

Two habits follow from that. Read the diagnostic and change what it names — resubmitting the same shape gets the same refusal. And check the exit code: piping atlas into anything swallows it, so a rejection reads as an empty success. A refusal has no result, and a // 0 default in jq will happily turn one into a real-looking zero.

A source that can't be opened at all refuses the same way, under code: "source-invalid": an upload that won't parse, a file past the size Atlas reads, a connection string that isn't a valid URL, a server below the version its kind supports. Those messages are written to name the fix, which is what makes them worth reading before a retry. Anything Atlas didn't write itself stays a bare internal error, because a driver's own text can echo a connection URL with the password in it.

When Atlas can't express something — comparing one field against another, say — it says so instead of approximating, and the route around it is usually an export.

atlas export — when SQL is the right tool

$ atlas export SalesOrder OrderLineItem

Export materializes entities to typed parquet in ~/atlas/exports/ — real DATE, TIMESTAMP and DECIMAL columns, so nothing needs parsing downstream. From there it's DuckDB — already installed in the sandbox — and ordinary SQL: medians, window functions, expression measures, the things agg deliberately doesn't do.

It exports the full set, not a page. Above 500,000 rows it refuses until you filter it with --json or answer the refusal with --force. Exports are session scratch — they're wiped between sessions, so re-export for freshness rather than trusting yesterday's file.

The rows stream to the file as they arrive, joined or not, so size costs time rather than memory. The run has a 180-second server budget, and an export that runs past it fails with a timeout and leaves no file behind, rather than handing you a short one that looks complete. A file that exists is the whole set. --force on a large export can run close to that budget, so raise your own command timeout past it.

Reach for export after agg, not instead of it. A join you run yourself in DuckDB has none of the duplicate-key guards the engine hop applies. Spanning two sources is not on its own a reason to export: write the join hop first, and let export answer the refusal if one comes back.

When the command line is the right surface

  • Sweeps. Ten entities counted in one call, or a --match across every source, beats ten clicks.
  • Scripts. The output shape is fixed, so jq pipelines and cron-able scripts keep working.
  • Real SQL. Export plus DuckDB covers the analysis Atlas won't compile.
  • Reproducibility. A query in a file is a query you can diff, review and run again.

For a sentence-shaped question, or a table you want to look at once, the Atlas chat and query console are faster. For anything you'll do more than twice, write it down here.

Where to go next