Agent skill
dlake
When an agent should use it: Drive the Commercient Data Lake / Data Hub CLI (`dlake`, npm `@commercient/dlake`) to build and operate a SQL Server backend and its auto-generated REST/GraphQL Data API (DAB). Use this skill whenever the task involves `dlake`, a Commercient Data Lake / Data Hub tenant, or building an app, backend, schema, stored procedure, API key, or Data API on that platform — even if the user just says "the data lake CLI", names a `dlk_` key, or asks how to create tables / expose entities / place transactional writes there. It encodes the correct command ORDERING and the non-obvious gotchas that otherwise cost repeated failed calls.
Install with "dlake skills install" or read it in the terminal with "dlake skills show dlake". Skills ship inside the CLI; the copy below mirrors the GitHub repository.
Driving the dlake CLI
Keep this skill current.
dlakeships updates often and this text is embedded in the CLI you have installed. Before relying on it, comparedlake --versionwithnpm view @commercient/dlake version; if they differ, runnpm install -g @commercient/dlake@latestand thendlake skills install, which overwrites the installed skill files with the current text.
dlake is the terminal/CI client for a Commercient Data Lake / Data Hub tenant — a per-tenant
Microsoft SQL Server database with a schema builder, an auto-generated Data API (Microsoft Data
API Builder / “DAB”) exposing REST + GraphQL, scoped API keys, row-level security, change events,
and MCP connectors for AI agents. Source is proprietary; the CLI ships as binaries + npm.
The platform is the backend. You build on it by: creating schema, exposing entities to the Data API,
minting a scoped key, and having an app call the REST/GraphQL endpoints. This skill exists because
the ordering of those steps and a handful of argument/behavior quirks are not obvious, and getting
them wrong produces confusing errors (a 403 on a valid key, EntityNotFound on a table that
exists). Follow the sequence and rules below and it goes smoothly.
Command surface
dlake login --domain <tenant> # auth with a dlk_ API key: env DLAKE_API_KEY | --api-key-stdin | no-echo prompt
dlake status # tenant + service health
dlake admin list # every control-plane tool your key can use (DDL, keys, exposure, DAB, RLS, events, registration wizard)
dlake admin <tool> --help # argument schema for one tool
dlake admin <tool> [--arg value ...] # invoke it (schema/keys/exposure/etc.)
dlake tool list # every data-plane tool your key can use (records, query, aggregate, ingest, export)
dlake tool <tool> [--arg value ...] # invoke it (create_record, query, execute_entity, ...)
dlake guide api | guide help | guide cli # live API + platform docs, and the CLI reference
Two planes: admin = control plane (define & expose schema, manage keys, restart DAB).
tool = data plane (read/write rows, run queries) — and it only sees exposed entities.
The canonical build sequence
Do it in this order. The two restart_dab calls are the load-bearing part.
# 1. Authenticate with a FULL-SCOPE admin key (the admin plane refuses scoped keys)
dlake login --domain <tenant>
dlake tool get_active_schema # confirm the schema you'll build in (usually DLO)
# 2. Define schema (operates on the ACTIVE schema)
dlake admin create_table --tableName <t> --columns @cols.json --primaryKey Id
dlake admin describe_table --tableName <t> # read it back: per-column typeToken, nullability, PK, FKs
dlake admin create_procedure --procedureName <p> --parameters @params.json --body @proc.sql
# 3. Expose every entity you want in the API, THEN apply with a restart
dlake admin set_entity_exposure --entity <t> --expose true # repeat per entity + proc
dlake admin restart_dab --confirm true
# 4. Now the data plane can see them — seed / read / execute
dlake tool create_record --help # confirm the row-payload arg name
dlake tool create_record --entity <t> ... # (or ingest_table for bulk, natural-key tables)
# 5. Mint a scoped key for the app, THEN restart again so DAB learns its role
dlake admin create_api_key --keyName <app> --expirationDays 180 --scope @scope.json
dlake admin restart_dab --confirm true # ← REQUIRED, see Rule 1
Rules (each prevents a specific failure)
-
After creating (or re-saving the scope of) a scoped API key,
restart_dabagain. DAB’s running config only gains the key’s per-key role (key_<id>) on regeneration. Skip this and every request with that key returns403 AuthorizationCheckFailedeven though the key and scope are correct. Unscoped keys are exempt (they use the shareddatalake_userrole DAB already knows). The restart must be driven by a full-scope admin key — a scoped key is locked out of the admin control plane, so it can never restart DAB for itself. (Revoking a key is the one case the platform regenerates immediately, so the dead role disappears at once.) -
The data plane only sees exposed entities.
create_record/read_records/execute_entity/export_tablereturnEntityNotFoundfor a table that exists but hasn’t been throughset_entity_exposure --expose true+restart_dab. Expose first, then use. Same for a new view or procedure. (Raw-SQL reads —query/export_query— go straight to the database and need no exposure.) -
The active schema (usually
DLO, not always) can’t be switched from the CLI — only the web UI’s schema dropdown switches it; there is deliberately noset_active_schematool. All DDL andingest_tableland in the active schema. Read it withdlake tool get_active_schema; to keep multiple apps tidy in one schema, prefix object names (e.g.pizza_orders). -
On Windows, pass array/object args as
@file.json. Inline quoted JSON gets mangled by the shell. This is the reliable path on every platform and works for any arg type;@@passes a literal leading@. Simple scalar arrays also accepta,b,c. -
Scope JSON shape:
[{ "entityName": "<name>", "canRead": true, "canExecute": true }]— pluscanCreate/canUpdate/canDeleteas needed, and an optionalschemaName(omit it for the tenant’s working schema; set it only for an external/lake schema such ass3_<conn>). Wrong field names fail with “empty entityName” (there is noentityfield) or “no actions selected” (there is no bareread/write). RESTPATCH/PUTby key need bothcanCreateandcanUpdate— DAB treats them as upserts. A scoped key cannot drive the admin control plane. -
Stored-procedure parameters: each is
{ name, dataType, maxLength?, direction? }— the type key isdataType, nottype(onlycreate_table --columnsusestype), and the name must start with@({"name":"@CartJson","dataType":"NVARCHAR","maxLength":-1};"CartJson"is rejected with “must start with ’@’”). Also avoid reserved T-SQL keywords as column names —LineNofails becauseLINENOis reserved, as areKey,Order,User,Percent,Public; rename toLineNumber/SeqNo. -
ingest_tablerefuses IDENTITY-PK tables (“Row-by-row upsert needs a key whose values come from the file”). Bulk-load only natural-key tables; seed identity-keyed tables withcreate_record/ RESTPOST. Ingest also needs thedata.ingestpermission and is refused for scope-restricted keys entirely — use an unscoped key for it. -
dlake query/dlake tool queryare SELECT-only (a singleSELECTorWITH … SELECT; no;-separated statements), capped at 10,000 rows, and require the active-schema qualifier (e.g.FROM DLO.<table>— an unqualified name resolves against the login’s default schema and errors). They are refused for scope-restricted keys unless the key was minted with theAllowRawSqlopt-in. Writes go throughcreate_record/execute_entity/ procedures. -
Registration and passwords are the user’s to enter.
dlake register startcreates an account and takes a portal password (--password-stdinor a prompt) — hand that step to the user; don’t run it for them. (dlake register status --watchthen surfaces the once-only 7-day bootstrap owner-admin key and saves it to a profile.) A lost/expired registration token is recovered withdlake register login --email <them>— again the user types the password. Once the Data Lake is seeded, the registration WIZARD (CRM + ERP connector setup, steps 4-5) is drivable with the tenant key throughdlake admin— theregistration_*tools:registration_statereportsCurrentStepand routes you; if it has not passed step 3, runregistration_capture_server_ip --ipAddress <the user's server/public IP>first (the CRM step is guarded on it); catalogs are read tools;registration_crm_connect/registration_connector_submittakefieldsas a JSON OBJECT (never a pre-encoded string), andregistration_connector_submitaccepts an optionalerpNameto override the registration’s ERP.registration_hosting_get/registration_hosting_set(dlake registration hosting show|set) carry the HOSTING choice — where the tenant’s gateway database lives:express, a SQL Server 2025 Express container dedicated to the tenant, provisioned at seeding. Every registration isexpressby default, and the choice is locked once the tenant is seeded (hosting_locked). The same rule applies: CRM/ERP credentials insidefieldsare the user’s to supply — never invent or reuse values from elsewhere.
Choosing a write path
- Multi-row writes that must be atomic (an order header + its line items): put them in a stored
procedure and expose it. REST/GraphQL writes are per-statement and can half-succeed, leaving
orphans. The proc takes a flat param set (pass nested data as a JSON string parameter and shred it
with
OPENJSON), runs in one transaction, and returns the created rowset. Invoke viaPOST /auth/{tenant}/api/<proc>(procedures are POST-only on DAB) ordlake tool execute_entity. - Single-row insert/update:
create_record/ RESTPOST/PATCH(PATCH is the preferred upsert — send only changed columns; never send computed/dl_*/rowversion columns). - Reads with joins/nesting/aggregates: prefer GraphQL (
/auth/{tenant}/graphql) — one round trip for nested reads once relationships are declared (set_relationship). Fall back to the read-only SQL endpoints only for what GraphQL can’t express (window functions,UNION, self-joins, grouping/sorting by a related entity’s column, nested aggregations, offset paging). On MCP/CLI there is no GraphQL surface at all — useread_records,aggregateandquerythere.
Connecting an app to the Data API
Never ship a dlk_ key to a browser. Put it in a tiny server-side proxy that adds the auth headers
and forwards to DAB. Two auth options (full endpoint shapes in the Data API (DAB) — HTTP contract
section below):
- Simplest (and what
dlakeitself does): send the key asX-API-Key: <tenant>:dlk_...to the auth-proxy path; it exchanges for you, server-side. Prefer this. - Explicit:
POST /api/auth/apikeys/exchange→ Bearer JWT, thenAuthorization: Bearer <jwt>.
Both also send X-MS-API-ROLE: datalake_user for an unscoped key, key_<apiKeyId> for a scoped one
(the id is in the create_api_key response and the JWT roles claim). The auth-proxy infers the role
from the JWT when the header is absent, but send it explicitly — it is what the API guide documents and
it fails loudly instead of silently picking a role.
See the Data API (DAB) — HTTP contract section at the end of this file when wiring the HTTP
layer — it has the base URLs, the exchange call, OData read params, write semantics
(PATCH/PUT/DELETE, concurrency dl_expected_ts), the stored-procedure POST contract, and the
events/SSE/webhook endpoints for live updates.
Discovering the truth on a live tenant
The platform evolves and tools have exact argument schemas — verify rather than guess:
dlake admin <tool> --help/dlake tool <tool> --help— the authoritative arg schema.dlake admin list/dlake tool list— what actually exists on this tenant.dlake admin list_exposed_entities(ordlake entities list) — what the Data API currently serves.dlake admin dab_status— whether the API container is running (after a restart).dlake admin dab_dropped_entities— exposed entities the last regenerate did not publish, with the reason for each and whether the served API still lacks them. Check this when an entity thatlist_exposed_entitiesshows as exposed answers EntityNotFound.dlake guide api— the live API guide (auth, REST/GraphQL, events, write semantics).
Exit codes: 0 success · 1 error · 2 usage error · 3 permission/auth denied.
dlake Data API (DAB) — HTTP contract
Read this when building the HTTP layer that talks to a tenant’s Data API. Everything here is also
retrievable live via dlake guide api; this is the distilled, app-building subset.
Base URLs
| Surface | URL |
|---|---|
| Data API (DAB) via auth proxy | https://datalake-ms-dab.commercient.com/auth/{tenant}/api/{entity} |
| GraphQL | https://datalake-ms-dab.commercient.com/auth/{tenant}/graphql |
| Auth API (key exchange) | https://datalake-ms-auth-api.commercient.com |
| DDL API (exports, aggregates, events) | https://datalake-ms-ddl-api.commercient.com |
{tenant} is the short slug (dot-free lowercase DNS label), not the portal hostname.
Authentication — two paths
A. Raw key (simplest for a server-side proxy). Send the key directly; the auth-proxy exchanges it:
X-API-Key: dlk_...
X-MS-API-ROLE: key_<apiKeyId> # scoped keys; unscoped keys use datalake_user
B. Explicit exchange (when you want to manage the ~60-min JWT yourself). Prefer A unless you have a reason to hold the JWT; if this call reports a network restriction, contact support:
curl -X POST https://datalake-ms-auth-api.commercient.com/api/auth/apikeys/exchange \
-H "Content-Type: application/json" \
-d '{"apiKey":"dlk_...","domain":"{tenant}","issueRefreshToken":false}'
## -> { "accessToken": "eyJ...", "expiresUtc": "..." }
Then: Authorization: Bearer <accessToken> plus X-MS-API-ROLE (datalake_user, or key_<apiKeyId>
for a scoped key). Find the role in the create_api_key response (apiKeyId) or the JWT roles claim.
A newly-minted scoped key returns
403 AuthorizationCheckFaileduntil a full-scope admin key runsdlake admin restart_dab --confirm true— DAB must regenerate to know thekey_<id>role.
Reading (REST, OData-style)
GET /auth/{tenant}/api/{entity}?$filter=City eq 'Atlanta'&$select=Name,City&$orderby=Name&$first=100
GET /auth/{tenant}/api/{entity}/{keyColumn}/{value} # by key
URL-encode filter values (spaces in $orderby=Id desc must be encoded). List responses are
{ "value": [ ... ], "nextLink": "..." }. OData $count and contains() are not supported —
use GraphQL for totals and substring search.
Soft-deleted rows are returned by every read surface
On a concurrency-protected table a delete flags the row rather than removing it, and the platform
keeps returning it: REST, GraphQL, the MCP data tools, the CLI and raw SQL all include
dl_deleted = 1 rows until an administrator sweeps them. Nothing filters them out server-side.
dl_deleted is the filter, and applying it is the caller’s job.
An active row is one where dl_deleted is NULL or 0. The column is only written once a row
is touched, so an untouched active row carries NULL and a bare dl_deleted eq false (or = 0)
hides every untouched row. Ask for active rows like this:
| Surface | Active-rows filter |
|---|---|
| REST (OData) | ?$filter=dl_deleted eq null or dl_deleted eq false |
MCP read_records | filter: dl_deleted eq null or dl_deleted eq false |
| CLI | dlake tool read_records --entity <Entity> --filter "dl_deleted eq null or dl_deleted eq false" |
SQL (dlake query, query tool) | WHERE ISNULL(dl_deleted, 0) = 0 |
The column exists only on concurrency-protected tables, so on any other table there is nothing to filter and these clauses do not apply.
Reading (GraphQL — preferred for joins/nesting/aggregates)
Declare relationships once (admin), then one restart, then nest in a single request:
dlake admin set_relationship --parentEntity orders --childEntity order_items \
--parentKeyColumn Id --childFkColumn OrderId --cardinality many
dlake admin restart_dab --confirm true
{ orders(first: 20, orderBy: { Id: DESC }) {
items { Id Status Total
order_items(first: 200) { items { MenuItemId Qty LineTotal } } }
hasNextPage endCursor } }
Relationships are DAB config only — they don’t create DB foreign keys and enforce nothing.
Writing (REST) — per-statement, NOT transactional
| Method | Semantics |
|---|---|
POST /api/{entity} | Insert (body = column values) |
PATCH /api/{entity}/{key}/{value} | Partial upsert — preferred, send only changed columns |
PUT /api/{entity}/{key}/{value} | Full replace (omitted columns → NULL; fragile) |
DELETE /api/{entity}/{key}/{value} | Delete (non concurrency-protected tables only) |
Hard rules:
- Never send computed/server-managed columns:
id(computed),TimeStamp(rowversion),dl_*audit columns — DAB returns400.dl_deletedis the only documented exception. - Concurrency-Protected (CP) tables: every UPDATE must echo the row’s current
TimeStampasdl_expected_tsin the body. Read it via the list endpoint immediately before the PATCH (key-path GET may be cached/stale). A stale value fails “Row was modified” — re-read and retry. - Soft-delete a CP row with
PATCH ... {"dl_deleted": true, "dl_expected_ts": <ts>}; a directDELETEon a CP table is rejected withDAB_CONCURRENCY_BLOCKED_DELETE. PATCH/PUTby key require the key to hold both create and update rights (upsert semantics).- Two mutations in one GraphQL document are not atomic. For atomicity, use a stored procedure.
- Data-plane failures return a generic
DatabaseOperationFailed. To see the real SQL error, switch the tenant’s DAB host mode todevelopment(PUT /api/ddl/dab/host-mode), restart DAB, and set it back toproductionwhen done.
Transactions (stored procedures)
Expose a proc, then POST a flat name→value body. It commits everything or nothing and returns the
created rowset:
POST /auth/{tenant}/api/{proc}
{ "Param1": "...", "CartJson": "[{\"menuItemId\":2,\"qty\":1,\"toppingIds\":[1,5]}]" }
## -> { "value": [ { ...returned row(s)... } ] } (HTTP 201)
- Procedure entities are POST-only by default (a read-only proc can opt into
GET). - Pass nested/array input as a JSON string parameter and shred it in the body with
OPENJSON. - In GraphQL a proc appears as
execute<ProcName>on the Query type (not Mutation). - Body pattern for atomicity:
SET XACT_ABORT ON; BEGIN TRY BEGIN TRAN ... COMMIT END TRY BEGIN CATCH IF @@TRANCOUNT>0 ROLLBACK; THROW END CATCH. - Editing a proc is
drop_procedure+create_procedure+ a restart. Rolled-back attempts still consume IDENTITY values — gaps are expected.
Live updates (events)
Row-change events from event-captured tables (dlake admin enable_event_capture --table <t>), all
Bearer-authenticated on the DDL API:
GET /api/ddl/events/changes?cursor=0&max=100 # poll
POST /api/ddl/events/stream/token -> { liveToken } # then:
GET /api/ddl/events/stream?token=...&coalesce=250 # SSE (live from now; &since=<id> replays)
POST /api/ddl/events/webhooks { name, url, filter:{tables,ops}, payloadQuery? } # -> whsec_ secret (once)
Handle both SSE frame shapes and don’t key on the event name: live frames are one event object
per change (event: insert|update|delete), while a &since= reconnect replays coalesced
event: batch frames ({ from, to, count, events: [...] }). Track the highest id (or to) and
send it as &since= on reconnect. A scoped key’s feed is narrowed to entities it may read; a stream
closes ~1 min after the key is revoked or re-scoped — treat an unexpected close as “re-exchange and
reconnect”. Webhook deliveries carry a Stripe-style HMAC signature (t=...,v1=..., 5-min window).
Aggregates & read-only SQL (DDL API)
POST /api/ddl/sql/aggregate { table, group_by:[{column,date_part?}], aggregates:[{function,column}], filters, having, order_by, top }
POST /api/ddl/sql/query { sql } # single SELECT / WITH…SELECT, ≤10,000 rows
POST /api/ddl/sql/export-query?format=csv|parquet { sql } # async signed-URL export, no row cap
Functions: count, count_distinct, sum, avg, min, max. All enforce RLS and key scope. The two raw-SQL
endpoints are refused for scope-restricted keys without the AllowRawSql opt-in, and no more than
10 of them may run concurrently per user (an 11th returns 409 Too many concurrent queries).
Security checklist for an app
- Keep the
dlk_key server-side (env var / secret manager); never expose to the browser. - Give the app a scoped key with the minimum actions (read the menu, execute the checkout proc).
- Lean on a stored proc for multi-row writes so ownership chaining means the key needs only
canExecuteon the proc, not direct table write grants. - Add Row-Level Security so a key/user sees only its own rows before going to production.
- Use the shortest workable key expiry —
expirationDaysmust be 7, 30, 90, or 180 — and revoke unused keys (revocation takes effect within ~30 s and kills live MCP sessions).